Use fastapi-vue-setup, merging its template scripts to old Paskia entry point and devserver. Simplified CLI, no longer uses serve subcommand. Fixed the URL displayed on banner to show to actual frontend/caddy server even in devmode.

This commit is contained in:
2026-01-25 03:15:50 +00:00
parent 2100a7e14f
commit 5ee7443801
16 changed files with 700 additions and 225 deletions
+77 -173
View File
@@ -1,16 +1,31 @@
import argparse
import asyncio
import ipaddress
import json
import logging
import os
from urllib.parse import urlparse
import uvicorn
from fastapi_vue.hostutil import parse_endpoint
from uvicorn import Config, Server
from paskia import globals as _globals
from paskia.bootstrap import bootstrap_if_needed
from paskia.config import PaskiaConfig
from paskia.fastapi import app as fastapi_app
from paskia.fastapi import reset as reset_cmd
from paskia.util import startupbox
from paskia.util.hostutil import normalize_origin
DEFAULT_HOST = "localhost"
DEFAULT_SERVE_PORT = 4401
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
"""
def is_subdomain(sub: str, domain: str) -> bool:
@@ -34,80 +49,6 @@ def validate_auth_host(auth_host: str, rp_id: str) -> None:
)
def parse_endpoint(
value: str | None, default_port: int
) -> tuple[str | None, int | None, str | None, bool]:
"""Parse an endpoint using stdlib (urllib.parse, ipaddress).
Returns (host, port, uds_path). If uds_path is not None, host/port are None.
Supported forms:
- host[:port]
- :port (uses default host)
- [ipv6][:port] (bracketed for port usage)
- ipv6 (unbracketed, no port allowed -> default port)
- unix:/path/to/socket.sock
- None -> defaults (localhost:4401)
Notes:
- For IPv6 with an explicit port you MUST use brackets (e.g. [::1]:8080)
- Unbracketed IPv6 like ::1 implies the default port.
"""
if not value:
return DEFAULT_HOST, default_port, None, False
# Port only (numeric) -> localhost:port
if value.isdigit():
try:
port_only = int(value)
except ValueError: # pragma: no cover (isdigit guards)
raise SystemExit(f"Invalid port '{value}'")
return DEFAULT_HOST, port_only, None, False
# Leading colon :port -> bind all interfaces (0.0.0.0 + ::)
if value.startswith(":") and value != ":":
port_part = value[1:]
if not port_part.isdigit():
raise SystemExit(f"Invalid port in '{value}'")
return None, int(port_part), None, True
# UNIX domain socket
if value.startswith("unix:"):
uds_path = value[5:] or None
if uds_path is None:
raise SystemExit("unix: path must not be empty")
return None, None, uds_path, False
# Unbracketed IPv6 (cannot safely contain a port) -> detect by multiple colons
if value.count(":") > 1 and not value.startswith("["):
try:
ipaddress.IPv6Address(value)
except ValueError as e: # pragma: no cover
raise SystemExit(f"Invalid IPv6 address '{value}': {e}")
return value, default_port, None, False
# Use urllib.parse for everything else (host[:port], :port, [ipv6][:port])
parsed = urlparse(f"//{value}") # // prefix lets urlparse treat it as netloc
host = parsed.hostname
port = parsed.port
# Host may be None if empty (e.g. ':5500')
if not host:
host = DEFAULT_HOST
if port is None:
port = default_port
# Validate IP literals (optional; hostname passes through)
try:
# Strip brackets if somehow present (urlparse removes them already)
ipaddress.ip_address(host)
except ValueError:
# Not an IP address -> treat as hostname; no action
pass
return host, port, None, False
def add_common_options(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
@@ -134,45 +75,44 @@ def main():
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
parser = argparse.ArgumentParser(
prog="paskia", description="Paskia authentication server"
prog="paskia",
description="Paskia authentication server",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=EPILOG,
)
sub = parser.add_subparsers(dest="command", required=True)
# serve subcommand
serve = sub.add_parser(
"serve", help="Run the server (production style, no auto-reload)"
)
serve.add_argument(
# Primary argument: either host:port or "reset" subcommand
parser.add_argument(
"hostport",
nargs="?",
help=(
"Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
"[ipv6][:port] | ipv6 | unix:/path.sock"
"[ipv6][:port] | ipv6 | unix:/path.sock | 'reset' for credential reset"
),
)
add_common_options(serve)
# reset subcommand
reset = sub.add_parser(
"reset",
help=(
"Create a credential reset link for a user. Provide part of the display name or UUID. "
"If omitted, targets the master admin (first Administration role user in an auth:admin org)."
),
)
reset.add_argument(
"query",
parser.add_argument(
"reset_query",
nargs="?",
help="User UUID (full) or case-insensitive substring of display name. If omitted, master admin is used.",
help="For 'reset' command: user UUID or substring of display name",
)
add_common_options(reset)
add_common_options(parser)
args = parser.parse_args()
if args.command == "serve":
host, port, uds, all_ifaces = parse_endpoint(args.hostport, DEFAULT_SERVE_PORT)
# Detect "reset" subcommand (first positional is "reset")
is_reset = args.hostport == "reset"
if is_reset:
endpoints = []
else:
host = port = uds = all_ifaces = None # type: ignore
# Parse endpoint using fastapi_vue.hostutil
endpoints = parse_endpoint(args.hostport, DEFAULT_PORT)
# Extract host/port/uds from first endpoint for config display and site_url
ep = endpoints[0] if endpoints else {}
host = ep.get("host")
port = ep.get("port")
uds = ep.get("uds")
# Collect and normalize origins, handle auth_host
origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
@@ -193,8 +133,13 @@ def main():
origins = [x for x in origins if not (x in seen or seen.add(x))]
# Compute site_url and site_path for reset links
# Priority: auth_host > first origin with localhost > http://localhost:port
if args.auth_host:
# Priority: PASKIA_SITE_URL (explicit) > auth_host > first origin with localhost > http://localhost:port
explicit_site_url = os.environ.get("PASKIA_SITE_URL")
if explicit_site_url:
# Explicit site URL from devserver or deployment config
site_url = explicit_site_url.rstrip("/")
site_path = "/" if args.auth_host else "/auth/"
elif args.auth_host:
site_url = args.auth_host.rstrip("/")
site_path = "/"
elif origins:
@@ -215,8 +160,6 @@ def main():
site_path = "/auth/"
# Build runtime configuration
from paskia.config import PaskiaConfig
config = PaskiaConfig(
rp_id=args.rp_id,
rp_name=args.rp_name or None,
@@ -230,8 +173,6 @@ def main():
)
# Export configuration via single JSON env variable for worker processes
import json
config_json = {
"rp_id": config.rp_id,
"rp_name": config.rp_name,
@@ -243,8 +184,6 @@ def main():
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
# Initialize globals (without bootstrap yet)
from paskia import globals as _globals # local import
asyncio.run(
_globals.init(
rp_id=config.rp_id,
@@ -255,80 +194,45 @@ def main():
)
# Print startup configuration
from paskia.util import startupbox
startupbox.print_startup_config(config)
# Bootstrap after startup box is printed
from paskia.bootstrap import bootstrap_if_needed
asyncio.run(bootstrap_if_needed())
# Handle recover-admin command (no server start)
if args.command == "reset":
from paskia.fastapi import reset as reset_cmd # local import
exit_code = reset_cmd.run(getattr(args, "query", None))
# Handle reset command (no server start)
if is_reset:
exit_code = reset_cmd.run(args.reset_query)
raise SystemExit(exit_code)
if args.command == "serve":
run_kwargs: dict = {
"log_level": "info",
}
# Dev mode: enable reload when FASTAPI_VUE_FRONTEND_URL is set
devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
# Dev mode: enable reload when PASKIA_DEVMODE is set
devmode = bool(os.environ.get("PASKIA_DEVMODE"))
if devmode:
# Security: dev mode must run on localhost:4402 to prevent
# accidental public exposure of the Vite dev server
if host != "localhost" or port != 4402:
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
run_kwargs["reload"] = True
run_kwargs["reload_dirs"] = ["paskia"]
# Suppress uvicorn startup messages in dev mode
run_kwargs["log_level"] = "warning"
run_kwargs: dict = {
"log_level": "info",
}
if uds:
run_kwargs["uds"] = uds
else:
if not all_ifaces:
run_kwargs["host"] = host
run_kwargs["port"] = port
if devmode:
# Security: dev mode must run on localhost:4402 to prevent
# accidental public exposure of the Vite dev server
if host != "localhost" or port != 4402:
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
run_kwargs["reload"] = True
run_kwargs["reload_dirs"] = ["paskia"]
# Suppress uvicorn startup messages in dev mode
run_kwargs["log_level"] = "warning"
if all_ifaces and not uds:
# Dev mode with all interfaces: use simple single-server approach
if devmode:
run_kwargs["host"] = "::"
run_kwargs["port"] = port
uvicorn.run("paskia.fastapi:app", **run_kwargs)
else:
# Production: run separate servers for IPv4 and IPv6
from uvicorn import Config, Server # noqa: E402 local import
if len(endpoints) > 1:
# Run separate servers for multiple endpoints (e.g. IPv4 + IPv6)
async def serve_all():
async with asyncio.TaskGroup() as tg:
for ep in endpoints:
tg.create_task(
Server(Config(app=fastapi_app, **run_kwargs, **ep)).serve()
)
from paskia.fastapi import (
app as fastapi_app, # noqa: E402 local import
)
async def serve_both():
servers = []
assert port is not None
for h in ("0.0.0.0", "::"):
try:
cfg = Config(
app=fastapi_app,
host=h,
port=port,
log_level="info",
)
servers.append(Server(cfg))
except Exception as e: # pragma: no cover
logging.warning(f"Failed to configure server for {h}: {e}")
tasks = [asyncio.create_task(s.serve()) for s in servers]
await asyncio.gather(*tasks)
asyncio.run(serve_both())
else:
uvicorn.run("paskia.fastapi:app", **run_kwargs)
asyncio.run(serve_all())
else:
uvicorn.run("paskia.fastapi:app", **run_kwargs, **endpoints[0])
if __name__ == "__main__":
+2 -2
View File
@@ -10,12 +10,12 @@ from paskia.authsession import EXPIRES, reset_expires
from paskia.fastapi import authz
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import (
frontend,
hostutil,
passphrase,
permutil,
querysafe,
useragent,
vitedev,
)
app = FastAPI()
@@ -77,7 +77,7 @@ async def general_exception_handler(_request, exc: Exception): # pragma: no cov
@app.get("/")
async def adminapp(request: Request, auth=AUTH_COOKIE):
return Response(*await frontend.read("/auth/admin/index.html"))
return Response(*await vitedev.read("/auth/admin/index.html"))
# -------------------- Organizations --------------------
+2 -2
View File
@@ -23,7 +23,7 @@ from paskia.authsession import (
from paskia.fastapi import authz, session, user
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
from paskia.globals import passkey as global_passkey
from paskia.util import frontend, hostutil, htmlutil, passphrase, userinfo
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
bearer_auth = HTTPBearer(auto_error=True)
@@ -180,7 +180,7 @@ async def forward_authentication(
if wants_html:
# Browser request - return full-page HTML with metadata
data_attrs = {"mode": e.mode, **e.metadata}
html = (await frontend.read("/int/forward/index.html"))[0]
html = (await vitedev.read("/int/forward/index.html"))[0]
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
return Response(
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
+18 -14
View File
@@ -5,11 +5,18 @@ from pathlib import Path
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi_vue import Frontend
from paskia.fastapi import admin, api, auth_host, ws
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import frontend, hostutil, passphrase
from paskia.util import hostutil, passphrase, vitedev
# Vue Frontend static files
frontend = Frontend(
Path(__file__).with_name("frontend-build"),
cached=["/auth/assets/"],
)
# Path to examples/index.html when running from source tree
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
@@ -43,10 +50,11 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
raise
# Restore info level logging after startup (suppressed during uvicorn init in dev mode)
if frontend.is_dev_mode():
if frontend.devmode:
logging.getLogger("uvicorn").setLevel(logging.INFO)
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
await frontend.load()
yield
@@ -59,19 +67,11 @@ app.mount("/auth/api/admin/", admin.app)
app.mount("/auth/api/", api.app)
app.mount("/auth/ws/", ws.app)
# In dev mode (PASKIA_DEVMODE=1), Vite serves assets directly; skip static files mount
if not frontend.is_dev_mode():
app.mount(
"/auth/assets/",
StaticFiles(directory=frontend.file("auth", "assets")),
name="assets",
)
@app.get("/auth/restricted/")
async def restricted_view():
"""Serve the restricted/authentication UI for iframe embedding."""
return Response(*await frontend.read("/auth/restricted/index.html"))
return Response(*await vitedev.read("/auth/restricted/index.html"))
# Navigable URLs are defined here. We support both / and /auth/ as the base path
@@ -86,7 +86,7 @@ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
The frontend handles mode detection (host mode vs full profile) based on settings.
Access control is handled via APIs.
"""
return Response(*await frontend.read("/auth/index.html"))
return Response(*await vitedev.read("/auth/index.html"))
@app.get("/admin", include_in_schema=False)
@@ -128,4 +128,8 @@ async def token_link(token: str):
if not passphrase.is_well_formed(token):
raise HTTPException(status_code=404)
return Response(*await frontend.read("/int/reset/index.html"))
return Response(*await vitedev.read("/int/reset/index.html"))
# Final catch-all route for frontend files (keep at end of file)
frontend.route(app, "/")