From 3a6e2bb370f78e5f0427f000cdc7232ccf3e0a62 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 1 Oct 2026 06:09:14 +0000 Subject: [PATCH] =?UTF-8?q?CLI:=20paskia=20satellite=20=20?= =?UTF-8?q?=E2=80=94=20one-step=20satellite-only=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same register_uplink path as the admin UI, runnable without any web interface (a satellite may be a purely local instance serving backend apps on one server): the handshake runs immediately, the link credential and pushed domains are stored, rejections are printed. With no database yet, one is created first (localhost domain + admin bootstrap) — the command is the whole setup; 'paskia' then serves the linked domains. --- paskia/__main__.py | 72 +++++++++++++++++++++++++++++++++++++++++++++ paskia/satellite.py | 7 ++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/paskia/__main__.py b/paskia/__main__.py index 152ca7b..f84b829 100644 --- a/paskia/__main__.py +++ b/paskia/__main__.py @@ -28,6 +28,7 @@ EPILOG = """\ Examples: paskia init example.com "Example Corporation" paskia migrate example.com + paskia satellite wss://key@auth.example.com paskia --listen 4402 --save paskia """ @@ -190,6 +191,60 @@ def cmd_migrate(args: argparse.Namespace) -> None: print(f"✅ {action} {db_file_path()} (domains: {', '.join(rp_ids)})") +def cmd_satellite(args: argparse.Namespace) -> None: + """Register this instance as a satellite using an upstream's link URL. + + The one setup step for a satellite-only installation: with no database + yet, one is created first (default localhost domain and admin + bootstrap, like 'paskia init'). The registration handshake runs right + here — the URL's initial key is spent, the permanent link credential + and the pushed domains are stored. Afterwards 'paskia' serves the + linked domains. + """ + from paskia import satellite + from paskia.db.lifecycle import kanta + + db_path = db_file_path() + fresh = not db_path.exists() + result = {} + if fresh: + # Satellite-only bootstrap: default config (localhost domain) and + # the standard admin seed. + @kanta.bootstrap + def _bootstrap(data: DB) -> None: + result["passphrase"] = bootstrap(data, config=Config()) + + async def _register(): + try: + await kanta.open() + except Exception as e: + raise SystemExit( + f"Could not open {db_path} (is a server running?): {e}" + ) from e + try: + return await satellite.register_uplink(args.url) + finally: + await kanta.close() + + try: + info = asyncio.run(_register()) + except (ValueError, RuntimeError) as e: + raise SystemExit(f"Registration failed: {e}") from e + + domains_list = ", ".join(info["domains"]) or "(none yet — the upstream can scope the link later)" + print(f"🛰 Linked to {info['url']} — serving: {domains_list}") + for rp_id, reason in (info.get("rejected") or {}).items(): + print(f"⚠️ Domain {rp_id} rejected: {reason}") + if fresh: + print("Created the database. Serve with: paskia") + configure_domains(listen=None) + registry = build_registry(_load_stored_config(db_path)) + log_reset_link( + registry.get("localhost").reset_link_url(result["passphrase"]), + "Admin registration (for the local admin interface):", + ) + + def cmd_serve(args: argparse.Namespace) -> None: """Open the combined database and serve all configured domains.""" db_path = db_file_path() @@ -297,11 +352,28 @@ def main(): "in the current directory is selected automatically.", ) + satellite_parser = argparse.ArgumentParser( + prog="paskia satellite", + description="Register this instance as a satellite of an upstream " + "paskia instance, using the link URL minted in the upstream's admin " + "interface (Satellites panel). The registration handshake runs " + "immediately; afterwards 'paskia' serves the linked domains. With no " + "database yet, one is created first — this is the whole setup of a " + "satellite-only installation.", + ) + satellite_parser.add_argument( + "url", + help="The link URL (wss://@) from the upstream's " + "Satellites panel. Single-use; valid for 10 minutes from minting.", + ) + argv = sys.argv[1:] if argv and argv[0] == "init": cmd_init(init_parser.parse_args(argv[1:])) elif argv and argv[0] == "migrate": cmd_migrate(migrate_parser.parse_args(argv[1:])) + elif argv and argv[0] == "satellite": + cmd_satellite(satellite_parser.parse_args(argv[1:])) else: cmd_serve(parser.parse_args(argv)) diff --git a/paskia/satellite.py b/paskia/satellite.py index 4887df1..0d533a8 100644 --- a/paskia/satellite.py +++ b/paskia/satellite.py @@ -309,7 +309,12 @@ async def register_uplink(url: str) -> dict: domains.init_registry(db.data().config) if manager.started: await manager.sync_uplinks() - return {"link": link_id, "domains": sorted(accepted), "rejected": rejected} + return { + "link": link_id, + "url": base_url, + "domains": sorted(accepted), + "rejected": rejected, + } class UplinkReplica: