Files
paskia/scripts/devserver.py
T
LeoVasanko e979dd6312 Devserver and proxy configs for multi-realm
- devserver bootstraps via one-shot 'paskia init' when no database
  exists (multi --rp-id, --rp-name/--auth-host/--origin apply to the
  default realm), then runs plain 'paskia' serve which reads all realm
  configuration from the database; legacy *.paskiadb is adopted by
  serve without init.
- Caddy origins iterate all bootstrap rp-ids.
- vite.config.js accepts a comma-separated PASKIA_AUTH_HOST list and
  proxies /.well-known/webauthn to the backend so ROR works in dev.
- caddy/auth/setup forwards /.well-known/openid-configuration and
  /.well-known/webauthn to paskia (they must not be swallowed by a
  static /.well-known/* file handler); Caddyfile.dev updated to match
  the generated dev config.
2026-09-06 14:49:27 +00:00

277 lines
9.2 KiB
Python
Executable File

#!/usr/bin/env -S uv run
"""Run Vite development server for Vue app and FastAPI backend with auto-reload."""
import argparse
import asyncio
import json
import os
import shutil
import subprocess
import sys
from contextlib import suppress
from pathlib import Path
from urllib.parse import urlparse
import tracerite
from paskia.db.legacy import find_legacy_databases
from paskia.db.paths import db_file_path
# Import utilities from scripts/fastapi-vue (not a package, so we adjust sys.path)
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
from devutil import ( # noqa: E402
ProcessGroup,
check_ports_free,
logger,
ready,
setup_cli,
setup_vite,
)
DEFAULT_VITE_PORT = 4403
DEFAULT_DEV_PORT = 4402
CADDY_PORT = 443 # HTTPS port for Caddy proxy
CADDY_HTTP_PORT = 80 # HTTP port for ACME challenges
CADDYFILE_SITE_BLOCK = """\
SITE_ADDR {
# WebSockets bypass directly to backend (workaround for bun proxy bug)
handle /auth/ws/* {
reverse_proxy BACKEND_ADDR
}
# Everything else goes to or via Vite
handle {
reverse_proxy VITE_ADDR
}
}
"""
def build_caddyfile(origins: list[str], viteurl: str, backurl: str) -> str:
"""Build a Caddyfile for the given origins."""
caddyfile_parts = []
for origin in origins:
parsed = urlparse(origin)
scheme = parsed.scheme or "https"
host = parsed.hostname or parsed.path
port = parsed.port or (CADDY_HTTP_PORT if scheme == "http" else CADDY_PORT)
if port in (80, 443):
site_addr = f"{scheme}://{host}"
else:
site_addr = f"{scheme}://{host}:{port}"
block = (
CADDYFILE_SITE_BLOCK.replace("SITE_ADDR", site_addr)
.replace("BACKEND_ADDR", backurl)
.replace("VITE_ADDR", viteurl)
)
caddyfile_parts.append(block)
return "\n".join(caddyfile_parts)
async def run_caddy(
origins: list[str], viteurl: str, backurl: str
) -> asyncio.subprocess.Process:
"""Start Caddy as HTTPS reverse proxy, wait for ready signal."""
caddy_path = shutil.which("caddy")
if not caddy_path:
logger.warning("Caddy not found. Install it to use --caddy option.")
raise SystemExit(1)
caddyfile = build_caddyfile(origins, viteurl, backurl)
cmd = ["sudo", caddy_path, "run", "--config", "-", "--adapter", "caddyfile"]
logger.info(">>> sudo caddy @ %s", " ".join(origins))
proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
proc.stdin.write(caddyfile.encode())
await proc.stdin.drain()
proc.stdin.close()
# Wait for ready signal or failure
while True:
if proc.returncode is not None:
remaining = await proc.stderr.read()
for line in remaining.decode().splitlines():
if line:
logger.info("caddy: %s", line)
logger.warning("Caddy startup failed (exit code %d)", proc.returncode)
raise SystemExit(1)
line = await proc.stderr.readline()
if not line:
continue
decoded = line.decode().rstrip()
if "serving initial configuration" in decoded:
break
# Parse and show errors during startup
if decoded:
try:
log = json.loads(decoded)
level = log.get("level", "")
if level in ("error", "fatal", "warn"):
logger.warning("caddy: %s", log.get("msg", decoded))
except json.JSONDecodeError:
if "error" in decoded.lower() or "fatal" in decoded.lower():
logger.warning("caddy: %s", decoded)
# Start background task to drain stderr
async def drain_caddy_stderr():
while True:
line = await proc.stderr.readline()
if not line:
break
decoded = line.decode().rstrip()
if decoded:
try:
log = json.loads(decoded)
level = log.get("level", "")
if level in ("error", "fatal", "warn"):
logger.warning("caddy: %s", log.get("msg", decoded))
except json.JSONDecodeError:
pass # Ignore non-JSON output after startup
asyncio.create_task(drain_caddy_stderr())
return proc
def _split_multi(values: list[str] | None) -> list[str]:
"""Split repeatable/comma-separated CLI values into a flat list."""
result = []
for value in values or []:
result.extend(part.strip() for part in value.split(",") if part.strip())
return result
def ensure_database(rp_ids: list[str], args: argparse.Namespace, listen: str) -> None:
"""Bootstrap paskia.kantadb via 'paskia init' when no database exists.
Realm options are init-only; 'paskia' (serve) reads all configuration
from the database. A legacy *.paskiadb database is adopted by serve,
so no init is run in that case either.
"""
if db_file_path().exists() or find_legacy_databases():
return
cmd = [sys.executable, "-m", "paskia", "init", f"--listen={listen}"]
for rp_id in rp_ids:
cmd.extend(["--rp-id", rp_id])
if args.rp_name:
cmd.extend(["--rp-name", args.rp_name])
if args.auth_host:
cmd.extend(["--auth-host", args.auth_host])
for origin in _split_multi(args.origins):
cmd.extend(["--origin", origin])
logger.info(">>> paskia init (first run)")
proc = subprocess.run(cmd, check=False) # noqa: S603
if proc.returncode != 0:
raise SystemExit(proc.returncode)
async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
"""Run the development server with all components."""
reporoot = Path(__file__).parent.parent
frontend_path = reporoot / "frontend"
if not (frontend_path / "package.json").exists():
logger.warning("Frontend source not found at %s", frontend_path)
raise SystemExit(1)
viteurl, npm_install, vite = setup_vite(args.listen, DEFAULT_VITE_PORT)
backurl, paskia = setup_cli("paskia", args.backend, DEFAULT_DEV_PORT)
rp_ids = _split_multi(args.rp_id) or ["localhost"]
ensure_database(rp_ids, args, listen=backurl.removeprefix("http://"))
# Serve: no realm options — all configuration lives in the database
paskia.extend(remaining)
# Set environment for subprocesses
os.environ["PASKIA_VITE_URL"] = viteurl
os.environ["PASKIA_BACKEND_URL"] = backurl
os.environ["PASKIA_DEV"] = "1"
if args.auth_host:
os.environ["PASKIA_AUTH_HOST"] = args.auth_host
async with ProcessGroup() as pg:
# Start Caddy first if requested (needs to bind ports)
if args.caddy:
caddy_origins = []
if args.auth_host:
auth_host = args.auth_host
if "://" not in auth_host:
auth_host = f"https://{auth_host}"
caddy_origins.append(auth_host)
for rp_id in rp_ids:
caddy_origins.append(f"https://{rp_id}")
for origin in _split_multi(args.origins):
if "://" not in origin:
origin = f"https://{origin}"
caddy_origins.append(origin)
seen: set = set()
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))]
caddy_proc = await run_caddy(caddy_origins, viteurl, backurl)
pg._procs.append(caddy_proc)
pg._cmds[caddy_proc.pid] = "caddy"
npm_proc = await pg.spawn(*npm_install, cwd=frontend_path)
await check_ports_free(viteurl, backurl)
await pg.spawn(*paskia)
await pg.wait(
npm_proc, ready(backurl, path="/auth/api/settings?from=devserver.py")
)
await pg.spawn(*vite, cwd=frontend_path)
def main():
tracerite.load()
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument(
"-l",
"--listen",
metavar="addr",
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
)
parser.add_argument(
"--backend",
metavar="addr",
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
)
parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy")
parser.add_argument(
"--rp-id",
action="append",
help="Relying Party ID(s) for first-run bootstrap (default: localhost). "
"Repeatable and comma-separated; the first is the default realm.",
)
parser.add_argument(
"--rp-name", help="Relying Party name of the default realm (bootstrap only)"
)
parser.add_argument(
"--origin",
action="append",
dest="origins",
help="Allowed origin(s), bootstrap only",
)
parser.add_argument("--auth-host", help="Dedicated auth host for the default realm")
args, remaining = parser.parse_known_args()
with suppress(KeyboardInterrupt):
asyncio.run(run_devserver(args, remaining))
HELP_EPILOG = """
Other options are forwarded to paskia [args]
JS_RUNTIME environment variable can be used to select the JS runtime:
npm, deno, bun, or full path to the runtime executable (node maps to npm).
"""
if __name__ == "__main__":
main()