From 632278d4ce9c639f2e186096c1dfda56f2beddae Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 5 Feb 2026 02:54:07 +0000 Subject: [PATCH] Change CLI to use --listen rather than positional hostport argument. Fix listening on all interfaces (runs lifespan twice, which needs to be OK). Update README with various CLI changes and more. --- README.md | 21 +++++++++++---------- paskia/bootstrap.py | 15 ++++++++------- paskia/db/background.py | 13 +++++-------- paskia/fastapi/__main__.py | 34 +++++++++++++++++++++------------- scripts/devserver.py | 20 +++++++++++++------- 5 files changed, 58 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index e8350e2..0b87550 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,12 @@ Single Sign-On (SSO): Users register once and authenticate across all applicatio Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run: ```fish -uvx paskia serve --rp-id example.com +uvx paskia --rp-id example.com ``` -On the first run it downloads the software and prints a registration link for the Admin. The server will start up on [localhost:4401](http://localhost:4401) *for authentication required*, serving for `*.example.com`. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id. +On the first run it downloads the software and prints a registration link for the Admin. The server starts on [localhost:4401](http://localhost:4401), serving authentication for `*.example.com`. For local testing, leave out `--rp-id`. -Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below). +For production you need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below). For a permanent install of `paskia` CLI command, not needing `uvx`: @@ -44,19 +44,20 @@ uv tool install paskia ## Configuration -There is no config file. Pass only the options on CLI: +There is no config file. All settings are passed as CLI options: ```text -paskia serve [options] +paskia [options] +paskia reset [user] # Generate passkey reset link ``` | Option | Description | Default | |--------|-------------|---------| -| Listen address | One of *host***:***port* (default all hosts, port 4401) or **unix:***path***/paskia.socket** (Unix socket) | **localhost:4401** | -| --rp-id *domain* | Main/top domain | **localhost** | -| --rp-name *"text"* | Name of your company or site | Same as rp-id | -| --origin *url* | Explicitly list the domain names served | **https://**_rp-id_ | -| --auth-host *domain* | Dedicated authentication site (e.g., **auth.example.com**) | **Unspecified:** we use **/auth/** on **every** site under rp-id.| +| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** | +| --rp-id *domain* | Main/top domain for passkeys | **localhost** | +| --rp-name *"text"* | Name shown during passkey registration | Same as rp-id | +| --origin *url* | Restrict allowed origins for WebSocket auth (repeatable) | All under rp-id | +| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site | ## Further Documentation diff --git a/paskia/bootstrap.py b/paskia/bootstrap.py index 4ba0123..6df2b6b 100644 --- a/paskia/bootstrap.py +++ b/paskia/bootstrap.py @@ -15,18 +15,18 @@ from paskia.util import hostutil, passphrase logger = logging.getLogger(__name__) # Shared log message template for admin reset links -ADMIN_RESET_MESSAGE = """\ -%s - +ADMIN_RESET_MESSAGE = """ 👤 Admin %s - Use this link to register a Passkey for the admin user! """ -def _log_reset_link(message: str, passphrase: str) -> str: +def _log_reset_link(passphrase: str, message: str | None = None) -> str: """Log a reset link message and return the URL.""" reset_link = hostutil.reset_link_url(passphrase) - logger.info(ADMIN_RESET_MESSAGE, message, reset_link) + if message: + logger.info(message) + logger.info(ADMIN_RESET_MESSAGE, reset_link) return reset_link @@ -41,7 +41,7 @@ async def bootstrap_system() -> None: reset_passphrase = db.bootstrap() # Log the reset link (this is separate from the transaction log) - _log_reset_link("✅ Bootstrap completed!", reset_passphrase) + _log_reset_link(reset_passphrase, "✅ Bootstrap completed!") async def check_admin_credentials() -> bool: @@ -72,6 +72,7 @@ async def check_admin_credentials() -> bool: if not db.get_user_credential_ids(admin_user.uuid): # Admin exists but has no credentials, create reset link + logger.info("⚠️ Admin user has no credentials!") token = passphrase.generate() expiry = authsession.reset_expires() @@ -81,7 +82,7 @@ async def check_admin_credentials() -> bool: expiry=expiry, token_type="admin registration", ) - _log_reset_link("⚠️ Admin user has no credentials!", token) + _log_reset_link(token) return True return False diff --git a/paskia/db/background.py b/paskia/db/background.py index 2232e4e..debb283 100644 --- a/paskia/db/background.py +++ b/paskia/db/background.py @@ -74,21 +74,18 @@ async def start_background(): _logger.debug("Background task in different event loop, restarting") _background_task = None else: - # Task is running in the same event loop - this is an error - raise RuntimeError( - "Background task is already running. " - "start_background() must not be called multiple times in the same event loop." + # Task is already running in same loop - idempotent, just return + # This happens with dual IPv4+IPv6 endpoints sharing the same process + _logger.debug( + "Background task already running in same loop, skipping" ) - except RuntimeError: - raise # Re-raise RuntimeError from above + return except Exception as e: _logger.debug("Error checking background task loop: %s, restarting", e) _background_task = None if _background_task is None: _background_task = asyncio.create_task(_background_loop()) - else: - _logger.debug("Background task already running: %s", _background_task) async def stop_background(): diff --git a/paskia/fastapi/__main__.py b/paskia/fastapi/__main__.py index 63b867d..936a20d 100644 --- a/paskia/fastapi/__main__.py +++ b/paskia/fastapi/__main__.py @@ -21,10 +21,10 @@ DEFAULT_PORT = 4401 EPILOG = """\ Examples: - paskia # localhost:4401 - paskia :8080 # All interfaces, port 8080 - paskia unix:/tmp/paskia.sock - paskia reset [user] # Generate passkey reset link + paskia # localhost:4401 + paskia -l :8080 # All interfaces, port 8080 + paskia -l /tmp/paskia.sock # Unix socket + paskia reset [user] # Generate passkey reset link """ @@ -81,32 +81,40 @@ def main(): epilog=EPILOG, ) - # Primary argument: either host:port or "reset" subcommand + # Subcommand for reset parser.add_argument( - "hostport", + "command", nargs="?", - help=( - "Endpoint (default: localhost:4401). Forms: host[:port] | :port | " - "[ipv6][:port] | ipv6 | unix:/path.sock | 'reset' for credential reset" - ), + help="Command: 'reset' for credential reset, or omit to run server", ) parser.add_argument( "reset_query", nargs="?", help="For 'reset' command: user UUID or substring of display name", ) + parser.add_argument( + "-l", + "--listen", + metavar="LISTEN", + help=( + "Endpoint to listen on (default: localhost:4401). " + "Forms: host:port port :port [ipv6]:port unix:path /path.sock" + ), + ) add_common_options(parser) args = parser.parse_args() - # Detect "reset" subcommand (first positional is "reset") - is_reset = args.hostport == "reset" + # Detect "reset" subcommand + is_reset = args.command == "reset" if is_reset: endpoints = [] else: + if args.command is not None: + raise SystemExit(f"Unknown command: {args.command}") # Parse endpoint using fastapi_vue.hostutil - endpoints = parse_endpoint(args.hostport, DEFAULT_PORT) + endpoints = parse_endpoint(args.listen, DEFAULT_PORT) # Extract host/port/uds from first endpoint for config display and site_url ep = endpoints[0] if endpoints else {} diff --git a/scripts/devserver.py b/scripts/devserver.py index 755c91a..07b5771 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -6,9 +6,9 @@ not from the installed package. It starts both the Vite frontend dev server and the FastAPI backend with auto-reload enabled. Usage: - uv run scripts/devserver.py [host:port] [options...] + uv run scripts/devserver.py [-l host:port] [options...] -The optional host:port argument sets where the Vite frontend listens. +The optional -l/--listen argument sets where the Vite frontend listens. All other options are forwarded to `paskia`. Backend always listens on localhost:4402. @@ -376,9 +376,15 @@ def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None: def main(): - # Parse optional hostport argument for Vite frontend + # Parse optional listen argument for Vite frontend parser = argparse.ArgumentParser(add_help=False) - parser.add_argument("hostport", nargs="?", default=None) + parser.add_argument( + "-l", + "--listen", + metavar="ENDPOINT", + default=None, + help="Vite frontend endpoint (default: localhost:4403)", + ) parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy") parser.add_argument("--rp-id", default="localhost", help="Relying Party ID") parser.add_argument( @@ -389,7 +395,7 @@ def main(): # Parse Vite endpoint vite_host, vite_port, vite_uds, all_ifaces = parse_endpoint( - args.hostport, DEFAULT_VITE_PORT + args.listen, DEFAULT_VITE_PORT ) if vite_uds: @@ -446,8 +452,8 @@ def main(): # Start Vite dev server run_vite(vite_url, vite_host, vite_port, env, args.auth_host) - # Build command with origin args (no serve subcommand, host:port is first arg) - cmd = ["paskia", f"localhost:{BACKEND_PORT}"] + # Build command with origin args + cmd = ["paskia", "-l", f"localhost:{BACKEND_PORT}"] # Pass through rp-id (always pass, has default) cmd.extend(["--rp-id", args.rp_id])