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.

This commit is contained in:
2026-02-05 02:54:07 +00:00
parent 3faaeee7be
commit 632278d4ce
5 changed files with 58 additions and 45 deletions
+11 -10
View File
@@ -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: Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
```fish ```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`: For a permanent install of `paskia` CLI command, not needing `uvx`:
@@ -44,19 +44,20 @@ uv tool install paskia
## Configuration ## 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 ```text
paskia serve [options] paskia [options]
paskia reset [user] # Generate passkey reset link
``` ```
| Option | Description | Default | | Option | Description | Default |
|--------|-------------|---------| |--------|-------------|---------|
| Listen address | One of *host***:***port* (default all hosts, port 4401) or **unix:***path***/paskia.socket** (Unix socket) | **localhost:4401** | | -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
| --rp-id *domain* | Main/top domain | **localhost** | | --rp-id *domain* | Main/top domain for passkeys | **localhost** |
| --rp-name *"text"* | Name of your company or site | Same as rp-id | | --rp-name *"text"* | Name shown during passkey registration | Same as rp-id |
| --origin *url* | Explicitly list the domain names served | **https://**_rp-id_ | | --origin *url* | Restrict allowed origins for WebSocket auth (repeatable) | All under rp-id |
| --auth-host *domain* | Dedicated authentication site (e.g., **auth.example.com**) | **Unspecified:** we use **/auth/** on **every** site under rp-id.| | --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
## Further Documentation ## Further Documentation
+8 -7
View File
@@ -15,18 +15,18 @@ from paskia.util import hostutil, passphrase
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Shared log message template for admin reset links # Shared log message template for admin reset links
ADMIN_RESET_MESSAGE = """\ ADMIN_RESET_MESSAGE = """
%s
👤 Admin %s 👤 Admin %s
- Use this link to register a Passkey for the admin user! - 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.""" """Log a reset link message and return the URL."""
reset_link = hostutil.reset_link_url(passphrase) 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 return reset_link
@@ -41,7 +41,7 @@ async def bootstrap_system() -> None:
reset_passphrase = db.bootstrap() reset_passphrase = db.bootstrap()
# Log the reset link (this is separate from the transaction log) # 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: 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): if not db.get_user_credential_ids(admin_user.uuid):
# Admin exists but has no credentials, create reset link # Admin exists but has no credentials, create reset link
logger.info("⚠️ Admin user has no credentials!")
token = passphrase.generate() token = passphrase.generate()
expiry = authsession.reset_expires() expiry = authsession.reset_expires()
@@ -81,7 +82,7 @@ async def check_admin_credentials() -> bool:
expiry=expiry, expiry=expiry,
token_type="admin registration", token_type="admin registration",
) )
_log_reset_link("⚠️ Admin user has no credentials!", token) _log_reset_link(token)
return True return True
return False return False
+5 -8
View File
@@ -74,21 +74,18 @@ async def start_background():
_logger.debug("Background task in different event loop, restarting") _logger.debug("Background task in different event loop, restarting")
_background_task = None _background_task = None
else: else:
# Task is running in the same event loop - this is an error # Task is already running in same loop - idempotent, just return
raise RuntimeError( # This happens with dual IPv4+IPv6 endpoints sharing the same process
"Background task is already running. " _logger.debug(
"start_background() must not be called multiple times in the same event loop." "Background task already running in same loop, skipping"
) )
except RuntimeError: return
raise # Re-raise RuntimeError from above
except Exception as e: except Exception as e:
_logger.debug("Error checking background task loop: %s, restarting", e) _logger.debug("Error checking background task loop: %s, restarting", e)
_background_task = None _background_task = None
if _background_task is None: if _background_task is None:
_background_task = asyncio.create_task(_background_loop()) _background_task = asyncio.create_task(_background_loop())
else:
_logger.debug("Background task already running: %s", _background_task)
async def stop_background(): async def stop_background():
+21 -13
View File
@@ -21,10 +21,10 @@ DEFAULT_PORT = 4401
EPILOG = """\ EPILOG = """\
Examples: Examples:
paskia # localhost:4401 paskia # localhost:4401
paskia :8080 # All interfaces, port 8080 paskia -l :8080 # All interfaces, port 8080
paskia unix:/tmp/paskia.sock paskia -l /tmp/paskia.sock # Unix socket
paskia reset [user] # Generate passkey reset link paskia reset [user] # Generate passkey reset link
""" """
@@ -81,32 +81,40 @@ def main():
epilog=EPILOG, epilog=EPILOG,
) )
# Primary argument: either host:port or "reset" subcommand # Subcommand for reset
parser.add_argument( parser.add_argument(
"hostport", "command",
nargs="?", nargs="?",
help=( help="Command: 'reset' for credential reset, or omit to run server",
"Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
"[ipv6][:port] | ipv6 | unix:/path.sock | 'reset' for credential reset"
),
) )
parser.add_argument( parser.add_argument(
"reset_query", "reset_query",
nargs="?", nargs="?",
help="For 'reset' command: user UUID or substring of display name", 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) add_common_options(parser)
args = parser.parse_args() args = parser.parse_args()
# Detect "reset" subcommand (first positional is "reset") # Detect "reset" subcommand
is_reset = args.hostport == "reset" is_reset = args.command == "reset"
if is_reset: if is_reset:
endpoints = [] endpoints = []
else: else:
if args.command is not None:
raise SystemExit(f"Unknown command: {args.command}")
# Parse endpoint using fastapi_vue.hostutil # 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 # Extract host/port/uds from first endpoint for config display and site_url
ep = endpoints[0] if endpoints else {} ep = endpoints[0] if endpoints else {}
+13 -7
View File
@@ -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. and the FastAPI backend with auto-reload enabled.
Usage: 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`. All other options are forwarded to `paskia`.
Backend always listens on localhost:4402. Backend always listens on localhost:4402.
@@ -376,9 +376,15 @@ def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None:
def main(): def main():
# Parse optional hostport argument for Vite frontend # Parse optional listen argument for Vite frontend
parser = argparse.ArgumentParser(add_help=False) 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("--caddy", action="store_true", help="Run Caddy as HTTPS proxy")
parser.add_argument("--rp-id", default="localhost", help="Relying Party ID") parser.add_argument("--rp-id", default="localhost", help="Relying Party ID")
parser.add_argument( parser.add_argument(
@@ -389,7 +395,7 @@ def main():
# Parse Vite endpoint # Parse Vite endpoint
vite_host, vite_port, vite_uds, all_ifaces = parse_endpoint( vite_host, vite_port, vite_uds, all_ifaces = parse_endpoint(
args.hostport, DEFAULT_VITE_PORT args.listen, DEFAULT_VITE_PORT
) )
if vite_uds: if vite_uds:
@@ -446,8 +452,8 @@ def main():
# Start Vite dev server # Start Vite dev server
run_vite(vite_url, vite_host, vite_port, env, args.auth_host) 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) # Build command with origin args
cmd = ["paskia", f"localhost:{BACKEND_PORT}"] cmd = ["paskia", "-l", f"localhost:{BACKEND_PORT}"]
# Pass through rp-id (always pass, has default) # Pass through rp-id (always pass, has default)
cmd.extend(["--rp-id", args.rp_id]) cmd.extend(["--rp-id", args.rp_id])