diff options
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client/hub.py')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/hub.py | 160 |
1 files changed, 160 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/hub.py b/packages/meshbay-cli/src/meshbay_cli/client/hub.py new file mode 100644 index 0000000..c8ce0c1 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/client/hub.py @@ -0,0 +1,160 @@ +""" +The hub's API, as a member's client uses it. + +The hub signs in, lists groups and the nodes serving them, mints the token a +node is shown, and relays the WebRTC offer. Nothing else: no listing, no file +and no byte of content passes through it (docs/MESHBAY_DESIGN.md §1.3, §8.4). +""" + +import logging +import time + +import httpx +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from .. import __version__ +from ..errors import AUTH, NODE, NOT_FOUND, VERSION, CliError +from .keys import b64 + +log = logging.getLogger(__name__) + +DEFAULT_HUB = "https://meshbay.org" + + +class HubError(Exception): + def __init__(self, status: int, detail: str): + super().__init__(f"{status}: {detail}") + self.status = status + self.detail = detail + + +def _version_tuple(v: str) -> tuple[int, ...]: + out = [] + for part in v.split("."): + digits = "".join(c for c in part if c.isdigit()) + out.append(int(digits or 0)) + return tuple(out) + + +class Hub: + def __init__(self, url: str): + self.url = url.rstrip("/") + self._http = httpx.AsyncClient( + base_url=self.url, timeout=30, + headers={"User-Agent": f"meshbay-cli/{__version__}"}) + + async def aclose(self) -> None: + await self._http.aclose() + + async def _call(self, method: str, path: str, *, token: str | None = None, + json: dict | None = None) -> dict: + headers = {"Authorization": f"Bearer {token}"} if token else {} + try: + r = await self._http.request(method, path, json=json, headers=headers) + except httpx.HTTPError as e: + raise CliError(NODE, f"Cannot reach the hub at {self.url}: {e}") from e + if r.status_code >= 400: + try: + detail = r.json().get("detail", r.text) + except Exception: + detail = r.text + raise HubError(r.status_code, str(detail)) + return r.json() if r.content else {} + + # ── Version ────────────────────────────────────────────────────────────── + + async def check_version(self) -> str | None: + """Refuse a client below the hub's minimum. Returns a notice, or None.""" + try: + info = await self._call("GET", "/v1/hub/version") + except HubError as e: + raise CliError(NODE, f"{self.url} does not answer as a MeshBay hub " + f"({e.status})") from e + client = info.get("client") or {} + mine = _version_tuple(__version__) + minimum = client.get("minimum") + if minimum and mine < _version_tuple(minimum): + raise CliError(VERSION, f"This hub needs meshbay-cli {minimum} or later; " + f"this is {__version__}.") + recommended = client.get("recommended") + if recommended and mine < _version_tuple(recommended): + return f"meshbay-cli {recommended} is available; this is {__version__}." + return None + + # ── Signing in ─────────────────────────────────────────────────────────── + + async def login(self, username: str, auth_key: str, + known_browser: str | None) -> dict: + body = {"username": username, "auth_key": auth_key} + if known_browser: + body["known_browser"] = known_browser + try: + return await self._call("POST", "/v1/users/login", json=body) + except HubError as e: + raise _sign_in_refusal(e) from e + + async def register_device(self, token: str, pk: str, label: str, auth_key: str) -> dict: + return await self._call("POST", "/v1/users/devices", token=token, json={ + "pk_auth_ed25519": pk, "label": label[:64], "auth_key": auth_key}) + + async def delete_device(self, token: str, device_id: str) -> None: + await self._call("DELETE", f"/v1/users/devices/{device_id}", token=token) + + async def device_auth(self, username: str, sk: Ed25519PrivateKey) -> dict: + ts = int(time.time()) + sig = sk.sign(f"meshbay:user_auth:{username}:{ts}".encode()) + try: + return await self._call("POST", "/v1/users/auth", json={ + "username": username, "timestamp": ts, "signature": b64(sig)}) + except HubError as e: + raise _sign_in_refusal(e) from e + + async def refresh(self, refresh_token: str) -> dict: + return await self._call("POST", "/v1/users/token/refresh", + json={"refresh_token": refresh_token}) + + async def logout(self, refresh_token: str) -> None: + await self._call("POST", "/v1/users/logout", json={"refresh_token": refresh_token}) + + async def me(self, token: str) -> dict: + return await self._call("GET", "/v1/users/me", token=token) + + # ── Groups and nodes ───────────────────────────────────────────────────── + + async def my_groups(self, token: str) -> list[dict]: + return (await self._call("GET", "/v1/groups/mine", token=token))["groups"] + + async def group_nodes(self, token: str, group_id: str) -> list[dict]: + try: + return (await self._call("GET", f"/v1/groups/{group_id}/nodes", + token=token))["nodes"] + except HubError as e: + if e.status in (403, 404): + raise CliError(NOT_FOUND, f"The hub refused the group: {e.detail}") from e + raise + + async def mnp_token(self, token: str, node_pk: str, group_id: str) -> str: + """A token for one node and one group, never the hub session (E10).""" + r = await self._call("POST", "/v1/nodes/mnp-token", token=token, + json={"node_pk": node_pk, "group_id": group_id}) + return r["mnp_token"] + + async def offer(self, token: str, node_id: str, sdp: str) -> str: + try: + r = await self._call("POST", f"/v1/nodes/{node_id}/webrtc/offer", token=token, + json={"sdp": sdp, "ice_candidates": []}) + except HubError as e: + raise CliError(NODE, f"The node did not answer the offer: {e.detail}") from e + return r["sdp"] + + +def _sign_in_refusal(e: HubError) -> CliError: + if e.status == 401: + return CliError(AUTH, "Sign-in refused: wrong username or passphrase.") + if e.status == 403 and e.detail == "email_verification_required": + return CliError(AUTH, "This account's e-mail address is not verified yet.") + if e.status == 403: + return CliError(AUTH, f"Sign-in refused: {e.detail}.") + if e.status == 429: + return CliError(AUTH, f"Too many attempts: {e.detail}") + return CliError(AUTH, f"Sign-in failed ({e.status}): {e.detail}") |