diff options
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/commands')
8 files changed, 486 insertions, 2 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/browser_access.py b/packages/meshbay-cli/src/meshbay_cli/commands/browser_access.py new file mode 100644 index 0000000..e2b0b8e --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/browser_access.py @@ -0,0 +1,72 @@ +""" +`browser-access`: whether this machine leaves, on each node, a copy of its keys +a browser can open with the passphrase — the application's Profile → Browser +access, kept by this machine (docs/MESHBAY_DESIGN.md §3.7). + +`on`: each group opened here leaves a copy sealed under the passphrase-derived +key, when its node holds none or an outdated one. `off`: the copy a node holds +is withdrawn. Never set: nothing is written or withdrawn, so a CLI nobody +configured never undoes what the application of another computer does. + +Set here and nowhere else. The hub keeps a mirror of it, written here as the +application writes it, but a mirror anyone with a hub session can write must +never make this machine upload keys: read, it only produces a message. +""" + +import json + +from ..client.hub import HubError +from ..errors import AUTH, CliError +from ..output import err, sentence +from .context import Context + +HELP = "whether a browser may open your groups: leave a copy of your keys on nodes, or not" + +EFFECT = { + "on": "Each group opened here from now on leaves a copy of your keys on its node, " + "sealed under your passphrase, so that a browser can open it.", + "off": "Each group opened here from now on withdraws the copy of your keys its node " + "holds; a browser will no longer open it with your passphrase.", +} + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("browser-access", parents=[parent], help=HELP, + description=sentence(HELP) + ". As the application's Browser " + "access, for this machine. With no argument, shows the setting.") + p.add_argument("state", nargs="?", choices=("on", "off")) + + +async def run(ctx: Context) -> int: + a = ctx.args + account = ctx.account() + if not account.remembered: + raise CliError(AUTH, "This setting is kept with the saved session; run " + "`meshbay-cli login` first.") + if a.state is None: + try: + mirror = (await account.call(account.hub.preferences)).get("browser_access") + except (HubError, CliError): + mirror = None + mine = account.browser_access + if a.json: + print(json.dumps({"this_machine": mine, "hub": mirror})) + else: + print(f"This machine: {mine or 'not set (leaves the nodes as they are)'}") + print(f"The hub's copy: {mirror or 'none'} (informative: it sets nothing here)") + return 0 + + def put(state: dict, secrets: dict) -> None: + state["browser_access"] = a.state + await account.update(put) + try: + await account.call(account.hub.set_preference, "browser_access", a.state) + except (HubError, CliError) as e: + err(f"meshbay-cli: warning: the hub's copy of the setting was not updated " + f"({getattr(e, 'detail', None) or getattr(e, 'message', e)}); a browser may " + "explain a closed group less well.") + if a.json: + print(json.dumps({"this_machine": a.state})) + else: + print(f"Browser access: {a.state}. {EFFECT[a.state]}") + return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/context.py b/packages/meshbay-cli/src/meshbay_cli/commands/context.py index c11533b..827c101 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/context.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/context.py @@ -71,6 +71,8 @@ class Context: "this group:\n" " the group's Settings → \"Your devices on this node\" → enter " f"{pending.link_code} → Approve\n" + "or from a machine already linked:\n" + f" meshbay-cli devices approve {handle(group)} {pending.link_code}\n" + (f"Waiting for the approval (the code expires in {minutes} min; " "Ctrl-C to stop)..." if waiting else f"Run the command again once it is approved (the code expires in " diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/decline.py b/packages/meshbay-cli/src/meshbay_cli/commands/decline.py new file mode 100644 index 0000000..23c7bb8 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/decline.py @@ -0,0 +1,43 @@ +"""`decline`: answer an invitation no. Asked first; `--yes` answers for a script.""" + +import json + +from ..client.hub import HubError +from ..client.paths import handle +from ..errors import NOT_FOUND, CliError +from ..output import confirm, safe, sentence +from .context import Context +from .invitations import find_invitation + +HELP = "decline an invitation" + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("decline", parents=[parent], help=HELP, description=sentence(HELP) + + ". Only a new invitation from the group's owner brings it back.") + p.add_argument("invitation", metavar="INVITATION", + help="the group, as `invitations` lists it") + p.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation") + + +async def run(ctx: Context) -> int: + await ctx.hub.check_version() + account = ctx.account() + try: + group = find_invitation(await account.call(account.hub.invitations), + ctx.args.invitation) + name = handle(group) + confirm(f"Decline the invitation to {safe(name)}?", ctx.args.yes) + try: + await account.call(account.hub.decline_invitation, group["id"]) + except HubError as e: + if e.status == 404: + raise CliError(NOT_FOUND, "That invitation is no longer waiting.") from e + raise + finally: + await account.close() + if ctx.args.json: + print(json.dumps({"declined": True, "path": name, "id": group["id"]})) + else: + print(f"Declined the invitation to {safe(name)}.") + return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/devices.py b/packages/meshbay-cli/src/meshbay_cli/commands/devices.py new file mode 100644 index 0000000..9fab241 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/devices.py @@ -0,0 +1,96 @@ +""" +`devices`: this account's devices on the node serving a group — what the +application's "Your devices on this node" shows and does. + +Identities are per node, so a group names the node. `approve` admits the device +showing a code; `revoke` retires one, asked first (`--yes` for a script). A node +where this machine is the only linked device admits no other without this. +""" + +import json +import sys + +from ..client import devices +from ..client.groups import my_groups, open_group +from ..client.paths import group_arg, handle, resolve_group +from ..errors import REFUSED, USAGE, CliError +from ..output import confirm, err, safe, sentence +from .context import Context + +HELP = "your devices on the node serving a group: list, approve a new one, revoke one" + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("devices", parents=[parent], help=HELP, description=sentence(HELP) + + ". A new device shows a code; approve it here, from a machine " + "already linked to that node.") + p.add_argument("verb", choices=("list", "approve", "revoke")) + p.add_argument("group", metavar="GROUP", help="the group, as `groups` lists it") + p.add_argument("arg", nargs="?", metavar="CODE|DEVICE", + help="approve: the code the new device shows; revoke: the device, by " + "the first characters of its key as `devices list` shows it") + p.add_argument("-y", "--yes", action="store_true", + help="revoke without asking for confirmation") + + +def _when(iso: str) -> str: + return (iso or "")[:16].replace("T", " ") + + +async def run(ctx: Context) -> int: + a = ctx.args + if a.verb != "list" and not a.arg: + raise CliError(USAGE, f"Name the {'code' if a.verb == 'approve' else 'device'}: " + f"meshbay-cli devices {a.verb} GROUP " + f"{'CODE' if a.verb == 'approve' else 'DEVICE'}") + await ctx.hub.check_version() + account = ctx.account() + session = None + try: + group = resolve_group(await my_groups(account), group_arg(a.group)) + name = handle(group) + session = await open_group(account, group) + if a.verb == "list": + return _print(ctx, name, await devices.list_devices(session)) + if a.verb == "approve": + pk = await devices.approve(session, account, a.arg) + print(json.dumps({"approved": pk}) if a.json else + f"Approved the device {pk[:16]} on the node serving {safe(name)}.") + return 0 + device = devices.find_device((await devices.list_devices(session))["devices"], + a.arg) + if device.get("is_this_one"): + raise CliError(REFUSED, "That is this machine. Revoke it from another of your " + "devices.") + label = device.get("label") or "no label" + confirm(f"Revoke the device {device['pk_ed25519'][:16]} ({safe(label)}) on the " + f"node serving {safe(name)}? It will no longer open this node's groups.", + a.yes) + await devices.revoke(session, account, device["pk_ed25519"]) + print(json.dumps({"revoked": device["pk_ed25519"]}) if a.json else + f"Revoked the device {device['pk_ed25519'][:16]}.") + return 0 + finally: + if session is not None: + await session.close() + await account.close() + + +def _print(ctx: Context, name: str, listed: dict) -> int: + rows = listed["devices"] + if ctx.args.json: + json.dump(listed, sys.stdout, indent=1) + print() + return 0 + print(f" {'KEY':<18}{'ADDED':<18}{'VIA':<12}LABEL") + for d in rows: + mark = "*" if d.get("is_this_one") else " " + print(f"{mark} {d['pk_ed25519'][:16]:<18}{_when(d.get('pinned_at')):<18}" + f"{safe(d.get('pinned_via') or ''):<12}{safe(d.get('label') or '')}") + if not ctx.args.quiet: + err("* this machine") + if listed["pending"]: + n = listed["pending"] + err(f"{n} device{'s' if n != 1 else ''} waiting for approval: " + f"meshbay-cli devices approve {name} CODE") + return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/groups.py b/packages/meshbay-cli/src/meshbay_cli/commands/groups.py index 7595e2b..04395dd 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/groups.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/groups.py @@ -7,12 +7,15 @@ it. "owner" is the account that created the group on the hub — a node's operator is another notion. """ +import contextlib import json import sys from datetime import datetime from ..client.groups import my_groups +from ..client.hub import HubError from ..client.paths import handle +from ..errors import CliError from ..output import safe, sentence from .context import Context @@ -31,7 +34,7 @@ def node_state(g: dict) -> str: return "up" if g.get("node_online") else "down" -def _when(iso: str | None) -> str: +def local_time(iso: str | None) -> str: if not iso: return "" try: @@ -45,6 +48,11 @@ async def run(ctx: Context) -> int: account = ctx.account() try: groups = await my_groups(account) + waiting = 0 + if not ctx.args.json and not ctx.args.quiet: + # A hub without invitations answers nothing useful; the list stands. + with contextlib.suppress(HubError, CliError): + waiting = len(await account.call(account.hub.invitations)) finally: await account.close() groups.sort(key=lambda g: (g["name"].lower(), g.get("owner_username") or "")) @@ -58,6 +66,9 @@ async def run(ctx: Context) -> int: for g in groups], sys.stdout, indent=1) print() return 0 + if waiting: + print(f"{waiting} invitation{'s' if waiting != 1 else ''} waiting for your answer: " + "meshbay-cli invitations", file=sys.stderr) if not groups: print("You belong to no group yet.", file=sys.stderr) return 0 @@ -65,5 +76,5 @@ async def run(ctx: Context) -> int: for g in groups: role = "owner" if g.get("is_admin") else "member" print(f"{node_state(g):<6}{role:<8}{safe(g.get('visibility') or ''):<12}" - f"{_when(g.get('last_activity_at')):<18}{safe(handle(g))}") + f"{local_time(g.get('last_activity_at')):<18}{safe(handle(g))}") return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/invitations.py b/packages/meshbay-cli/src/meshbay_cli/commands/invitations.py new file mode 100644 index 0000000..df2a23e --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/invitations.py @@ -0,0 +1,66 @@ +""" +`invitations`: the groups somebody added this account to, waiting for an answer. + +Until it is accepted an invitation is not a membership: the group is not in +`groups`, and its nodes cannot be reached. `join` accepts one, `decline` refuses +it. +""" + +import json +import sys + +from ..client.paths import group_arg, handle, resolve_group +from ..errors import NOT_FOUND, CliError +from ..output import safe, sentence +from .context import Context +from .groups import local_time + +HELP = "list the invitations waiting for your answer" + + +def add_parser(sub, parent) -> None: + sub.add_parser("invitations", parents=[parent], help=HELP, description=sentence(HELP) + + ". The GROUP column is what `join` and `decline` take.") + + +def as_group(inv: dict) -> dict: + """An invitation row in the shape `groups` rows have, for `handle` and lookups.""" + return {"id": inv["group_id"], "name": inv["name"], + "owner_username": inv.get("owner_username") or "", "invitation": inv} + + +def find_invitation(invitations: list[dict], spec: str) -> dict: + try: + return resolve_group([as_group(i) for i in invitations], group_arg(spec)) + except CliError as e: + if e.code != NOT_FOUND: + raise + raise CliError(NOT_FOUND, f"No invitation to a group called {spec!r} is " + "waiting; `meshbay-cli invitations` lists them.") from None + + +async def run(ctx: Context) -> int: + await ctx.hub.check_version() + account = ctx.account() + try: + invitations = await account.call(account.hub.invitations) + finally: + await account.close() + rows = sorted((as_group(i) for i in invitations), + key=lambda g: (g["name"].lower(), g["owner_username"])) + if ctx.args.json: + json.dump([{"group": g["name"], "owner": g["owner_username"], "id": g["id"], + "path": handle(g), "invited_by": g["invitation"].get("invited_by") or "", + "created_at": g["invitation"].get("created_at")} + for g in rows], sys.stdout, indent=1) + print() + return 0 + if not rows: + print("No invitation is waiting.", file=sys.stderr) + return 0 + print(f"{'INVITED BY':<22}{'DATE':<18}GROUP") + for g in rows: + inv = g["invitation"] + print(f"{safe(inv.get('invited_by') or ''):<22}{local_time(inv.get('created_at')):<18}" + f"{safe(handle(g))}") + return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/join.py b/packages/meshbay-cli/src/meshbay_cli/commands/join.py new file mode 100644 index 0000000..ee75c96 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/join.py @@ -0,0 +1,139 @@ +""" +`join`: become a member of a group, and be admitted by its node. + +Two halves, as in the application (docs/MESHBAY_DESIGN.md §3.4): the hub makes +the account a member — an invitation accepted, or an invitation link redeemed — +and the node admits it, wrapping the group key for a key pair made here and +kept. The node is dialled at once, so the group can be used as soon as this +returns. A link's code goes to the node the link names and to no other; a node +that is away gets the code at the next connection, kept until then. +""" + +import json +import sys + +from ..client.groups import my_groups, open_group +from ..client.hub import HubError +from ..client.invite import parse_link +from ..client.paths import group_arg, handle, resolve_group +from ..client.session import DeviceLinkPending, NodeRefusal +from ..errors import AUTH, NODE, NOT_FOUND, USAGE, CliError +from ..output import err, safe, sentence +from .context import Context +from .invitations import find_invitation + +HELP = "join a group: accept an invitation, or use an invitation link" + +BROWSER_HINT = ("A browser will not open {group} with your passphrase: this machine " + "leaves no copy of your keys on its node. To leave one, as the " + "application does with Browser access on: meshbay-cli browser-access on") + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("join", parents=[parent], help=HELP, description=sentence(HELP) + + ". The group's node is reached at once and admits this " + "machine with its own keys.") + p.add_argument("target", metavar="INVITATION|LINK", + help="an invitation as `invitations` lists it, an invitation link as " + "it was sent, or a group of yours whose node asked for a code") + p.add_argument("--code", help="the invitation's code (XXXX-XXXX), when the node asks " + "for one") + + +async def _membership(ctx: Context, account, target: str) -> tuple[str, str | None, + str | None]: + """The hub's half: the group's id, and the code and node a link carries.""" + link = parse_link(target) + if link is not None: + if link.origin and link.origin.rstrip("/") != ctx.hub_url: + raise CliError(USAGE, f"This link was made on {link.origin}; run the command " + f"with --hub {link.origin}.") + try: + preview = await account.call(account.hub.preview_link, link.ticket) + if not preview.get("already_member"): + await account.call(account.hub.redeem_link, link.ticket) + except HubError as e: + if e.status in (403, 404): + raise CliError(NOT_FOUND, "This invitation link no longer works: used by " + "another account, cancelled, or expired. Ask " + "for a new one.") from e + raise + if not ctx.args.quiet and not ctx.args.json: + err(f"Joining {safe(preview.get('group_name') or '')}, invited by " + f"{safe(preview.get('inviter') or '?')}.") + return link.group_id, link.code, link.node_pk + + invitations = await account.call(account.hub.invitations) + try: + group = find_invitation(invitations, target) + except CliError as e: + if e.code != NOT_FOUND: + raise + # Already a member: only the node's half is left to do. + group = resolve_group(await my_groups(account), group_arg(target)) + return group["id"], None, None + try: + await account.call(account.hub.accept_invitation, group["id"]) + except HubError as e: + if e.status == 404: + raise CliError(NOT_FOUND, "That invitation is no longer waiting.") from e + raise + return group["id"], None, None + + +async def run(ctx: Context) -> int: + a = ctx.args + await ctx.hub.check_version() + account = ctx.account() + if not account.remembered: + raise CliError(AUTH, "Joining needs a saved session: the keys made for the group " + "are kept on this machine. Run `meshbay-cli login` first.") + group_id, link_code, node_pk = await _membership(ctx, account, a.target) + group = next((g for g in await my_groups(account) if g["id"] == group_id), None) + if group is None: + raise CliError(NOT_FOUND, "The hub did not make this account a member.") + name = handle(group) + code = (a.code or link_code or "").strip().upper() or None + + while True: + try: + session = await open_group(account, group, join_code=code, node_pk=node_pk) + break + except NodeRefusal as e: + if e.refusal_code != "code_required" or code: + raise + if not sys.stdin.isatty(): + raise CliError(NODE, f"You are a member of {safe(name)}; its node has not " + "admitted you yet and needs your invitation's code:\n" + f" meshbay-cli join {name} --code CODE") from e + sys.stderr.write(f"The node asks for your invitation's code to {name} " + "(XXXX-XXXX): ") + sys.stderr.flush() + code = sys.stdin.readline().strip().upper() or None + if not code: + raise CliError(USAGE, "No code given.") from e + except CliError as e: + if e.code != NODE or not code or isinstance(e, NodeRefusal | DeviceLinkPending): + raise + await account.keep_join_code(group_id, code, node_pk) + raise CliError(NODE, f"You are a member of {safe(name)}, but its node cannot " + f"be reached now ({e.message}). The code is kept and " + "sent at the next connection, such as:\n" + f" meshbay-cli ls {name}/") from e + await session.close() + + if a.json: + print(json.dumps({"joined": True, "group": group["name"], + "owner": group.get("owner_username"), "id": group_id, + "path": name})) + else: + print(f"Joined {safe(name)}. Its files: meshbay-cli ls {safe(name)}/") + if account.browser_access is None and not a.quiet: + try: + prefs = await account.call(account.hub.preferences) + except (HubError, CliError): + prefs = {} + # The hub's word can only produce this line, never a copy of the keys. + if prefs.get("browser_access") != "off": + err(BROWSER_HINT.format(group=safe(name))) + return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/leave.py b/packages/meshbay-cli/src/meshbay_cli/commands/leave.py new file mode 100644 index 0000000..e9a529e --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/leave.py @@ -0,0 +1,55 @@ +""" +`leave`: give up a membership. Asked first; `--yes` answers for a script. + +The hub's half only, as in the application: the account stops being a member, +so its nodes can no longer be reached. The node keeps what it holds — the +identity it pinned, its copy of the keys, the files uploaded — until its +operator unpins them, and the group key already served opens what it opened +until the operator rotates it. The owner cannot leave a group. +""" + +import json + +from ..client.groups import my_groups +from ..client.hub import HubError +from ..client.paths import group_arg, handle, resolve_group +from ..errors import REFUSED, CliError +from ..output import confirm, safe, sentence +from .context import Context + +HELP = "leave a group" + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("leave", parents=[parent], help=HELP, description=sentence(HELP) + + ". Its files and its chat are no longer reachable from this " + "account; only a new invitation brings it back. The owner cannot " + "leave a group.") + p.add_argument("group", metavar="GROUP", help="the group, as `groups` lists it") + p.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation") + + +async def run(ctx: Context) -> int: + await ctx.hub.check_version() + account = ctx.account() + try: + group = resolve_group(await my_groups(account), group_arg(ctx.args.group)) + name = handle(group) + if group.get("is_admin"): + raise CliError(REFUSED, f"You own {safe(name)}, so you cannot leave it: it " + "would be left with nobody able to manage it.") + confirm(f"Leave {safe(name)}? Its files and chat will no longer be reachable " + "from your account.", ctx.args.yes) + try: + await account.call(account.hub.leave_group, group["id"]) + except HubError as e: + if e.status == 409: + raise CliError(REFUSED, e.detail) from e + raise + finally: + await account.close() + if ctx.args.json: + print(json.dumps({"left": True, "path": name, "id": group["id"]})) + else: + print(f"Left {safe(name)}.") + return 0 |