diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
7 files changed, 56 insertions, 29 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/cli/members.py b/packages/meshbay-node/src/meshbay_node/cli/members.py index ba68056..5d26bd0 100644 --- a/packages/meshbay-node/src/meshbay_node/cli/members.py +++ b/packages/meshbay-node/src/meshbay_node/cli/members.py @@ -62,18 +62,15 @@ def member(args) -> None: print("which is what turning the old switch off meant.") sys.exit(1) - if not args.target: - print(f"usage: meshbay-node member {sub} <username>") - sys.exit(1) - if sub == "invite" and args.link: - # A link for someone who may have no account yet, bound to their - # address on the hub. The code and the ticket are both in it, so - # it goes to them and to nobody else — the CLI mails nothing. + # A link for someone who may have no account yet, redeemable by the + # first account that opens it. The code and the ticket are both in + # it, so it goes to them and to nobody else — the CLI mails nothing. + # The address, if given, only labels the link in the owner's list. group_id = _resolve_group(cfg, args.group) + query = f"?email={quote(args.target)}" if args.target else "" out = _daemon_api( - cfg, f"/api/groups/{group_id}/invite-links?email={quote(args.target)}", - method="POST") + cfg, f"/api/groups/{group_id}/invite-links{query}", method="POST") from meshbay_node.roster import write_code_file write_code_file(cfg.data_dir, out["link"], out.get("expires_at", ""), name="invite-link") @@ -81,11 +78,15 @@ def member(args) -> None: print(f"valid until {out.get('expires_at', '?')}") print(f"cancel with meshbay-node member cancel {out['invite_id']}") print() - print(f"Send it to {args.target} yourself. It works once, and only for an") - print("account registered with that address: they open it, create their") - print("account or sign in, and land in the group without typing a code.") + print("Send it yourself, by any messaging app. It works once, for seven") + print("days, for whoever opens it first: they create their account or") + print("sign in, and land in the group without typing a code.") return + if not args.target: + print(f"usage: meshbay-node member {sub} <username>") + sys.exit(1) + if sub == "invite": group_id = _resolve_group(cfg, args.group) out = _daemon_api( diff --git a/packages/meshbay-node/src/meshbay_node/cli/parser.py b/packages/meshbay-node/src/meshbay_node/cli/parser.py index c1f15be..29a9f63 100644 --- a/packages/meshbay-node/src/meshbay_node/cli/parser.py +++ b/packages/meshbay-node/src/meshbay_node/cli/parser.py @@ -52,8 +52,8 @@ def build_parser() -> argparse.ArgumentParser: "install|remove|start|stop|status for autostart and " "for service") parser.add_argument("target", nargs="?", - help="username for member invite|revoke|unpin (an e-mail address with " - "--link, a link id for member cancel); group name " + help="username for member invite|revoke|unpin (an optional e-mail label " + "with --link, a link id for member cancel); group name " "for group add; file id for file rm; identifier for " "denylist clear; download cap for transfers set; " "size in GB for transfers max-size") @@ -73,8 +73,8 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--group", default=None, help="group id (optional if only one is configured)") parser.add_argument("--link", action="store_true", - help="member invite: an invitation link for this e-mail " - "address, for someone who may have no account yet") + help="member invite: an invitation link, for someone who " + "may have no account yet (valid 7 days, single use)") parser.add_argument("--writable", action="store_true", default=None, dest="writable", help="root accepts member uploads (root add/set)") diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 4800dac..dc5df81 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -983,6 +983,23 @@ class NodeDaemon(EnrichmentMixin): self._config.hub.username, self._config.hub.url, ) await asyncio.sleep(5) + elif e.response.status_code in (429, 500, 502, 503, 504): + # Transient: the hub is busy (429 — often this daemon's own + # retry storm against the sign-in rate limit), restarting + # (502/503) or erroring (500/504). None of these is a reason + # to exit: the daemon exiting here crash-loops under systemd + # and strands the operator, who needs it alive to read the + # node key (`meshbay-node status`, the desktop client) so + # they can link it. Back off — respecting Retry-After when + # the hub sends one — and try again, rather than dying. + self._state["status"] = "waiting_for_hub" + delay = 10 + ra = (e.response.headers or {}).get("Retry-After") + if ra and str(ra).isdigit(): + delay = min(max(delay, int(ra)), 300) + log.warning("Hub returned %s on login — retrying in %ds", + e.response.status_code, delay) + await asyncio.sleep(delay) else: raise except Exception as e: diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index 06d3503..346a4cd 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -135,8 +135,14 @@ class HubClient: access_token = data["access_token"] from meshbay_common.handshake import JWT_LEEWAY_SECONDS + from meshbay_common.tokens import HUB_API_AUD + # This is the node's own hub-API session token (scope=node), so it + # carries aud=HUB_API_AUD and must be decoded with that audience — the + # node reads its own exp/scope/jti here. It is a different credential + # from the MNP token a member presents in the handshake (aud=MNP_AUD), + # which authorize_token binds separately. decoded = jwt.decode(access_token, hub_pk_pem, algorithms=["EdDSA"], - leeway=JWT_LEEWAY_SECONDS) + leeway=JWT_LEEWAY_SECONDS, audience=HUB_API_AUD) # No pk_user claim to check any more: tokens carry no key. What binds this # token to this node is the Ed25519 challenge it was issued against. assert "jti" in decoded, "Hub token missing jti — hub is outdated" @@ -266,7 +272,7 @@ class HubClient: async def create_invite_link(self, group_id: str, email: str, expires_at: str, node_invite_id: str) -> dict: """ - The hub's half of an invitation link: a ticket bound to `email`. + The hub's half of an invitation link: a ticket, labelled `email` if any. Never with `send_email`: the hub refuses mail to a node token, and the CLI mails nothing — the operator sends the link (docs/USERGUIDE.md §7). diff --git a/packages/meshbay-node/src/meshbay_node/ops/members.py b/packages/meshbay-node/src/meshbay_node/ops/members.py index 2fd3899..b5da9c0 100644 --- a/packages/meshbay-node/src/meshbay_node/ops/members.py +++ b/packages/meshbay-node/src/meshbay_node/ops/members.py @@ -159,15 +159,13 @@ async def create_link_invite(state: dict, group_id: str, *, Nothing is registered on the hub here, unlike `create_invite`: there is no account to register yet. The hub half is a ticket the inviter's client asks - the hub for, bound to the invitee's address (docs/MESHBAY_DESIGN.md §7.3). + the hub for, redeemable by the first account that opens the link + (docs/MESHBAY_DESIGN.md §7.3). Seven days, whatever `invite_ttl_hours` says. """ roster = _roster(state) _group_ctx(state, group_id) - config = state.get("config") - ttl = (config.node.invite_ttl_hours if config else 168) * 3600 try: - code, invite_id, expires = await roster.create_link_invite( - group_id, created_by, ttl=ttl) + code, invite_id, expires = await roster.create_link_invite(group_id, created_by) except LinkInviteLimit as e: raise OpError(str(e), status=429) from e log.info("Invitation link issued: group=%s invite=%s", group_id[:8], invite_id[:8]) @@ -199,11 +197,12 @@ def _invite_url(hub_url: str, group_id: str, ticket: str, node_pk_b64: str, code return f"{origin}/#/invite?v=1&g={group_id}&t={ticket}&n={n}&c={code}" -async def create_link_invitation(state: dict, group_id: str, email: str, *, +async def create_link_invitation(state: dict, group_id: str, email: str = "", *, created_by: str = "local-cli") -> dict: """ A whole invitation link, from the operator's own machine: the node's code, - then the hub's ticket bound to `email`, then the link. + then the hub's ticket, then the link. `email` is optional and only labels + the link in the owner's list. In that order because the ticket names the code's handle. A ticket the hub refuses takes the code back with it — a code nobody can reach the node with @@ -211,8 +210,8 @@ async def create_link_invitation(state: dict, group_id: str, email: str, *, the operator sends the link. """ email = (email or "").strip() - if "@" not in email: - raise OpError("An invitation link is bound to an e-mail address", status=422) + if email and "@" not in email: + raise OpError("Not an e-mail address", status=422) hub = _hub(state) sk_node = state.get("sk_node") if sk_node is None: diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 80bf16d..8144373 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -54,6 +54,10 @@ CODE_LEN = 8 # 8 × 5 bits = 40 bits of entropy # node-wide lockout. DEFAULT_INVITE_TTL = 7 * 24 * 3600 # seconds — member invitations DEFAULT_PAIR_TTL = 24 * 3600 # seconds — operator pairing +# An invitation link is a bearer secret that may travel through any messaging +# service, so its lifetime is fixed rather than the operator's setting: the +# hub clamps its ticket to the same seven days. +LINK_INVITE_TTL = 7 * 24 * 3600 # A device-add code is read off one screen and typed into another, in one # sitting. An hour is comfort, not security: the code is bound to the requesting # keys by its hash, so a longer window widens nothing an attacker can use. @@ -1109,7 +1113,7 @@ class Roster: return code async def create_link_invite( - self, group_id: str, created_by: str, ttl: int = DEFAULT_INVITE_TTL, + self, group_id: str, created_by: str, ttl: int = LINK_INVITE_TTL, ) -> tuple[str, str, str]: """ Issue a code bound to no account: `(code, invite_id, expires_at)`. diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index eabe97a..090eb21 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -325,7 +325,7 @@ def create_ui_app(state: dict) -> FastAPI: # Both halves, node and hub, for the CLI: an operator at the machine gets a # whole link, not a code without a ticket. @app.post("/api/groups/{group_id}/invite-links") - async def create_link_invite(group_id: str, email: str): + async def create_link_invite(group_id: str, email: str = ""): return await _op(lambda: ops.create_link_invitation(state, group_id, email)) @app.delete("/api/groups/{group_id}/invite-links/{invite_id}") |