mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 18:12:18 -05:00
Phase 1 created auth_api_key and left PrincipalKind.APIKEY unproduced. This fills both in, so a printer or a script has a credential that is not somebody's password. A key is presented as spoolman_<prefix>.<secret>. The marker exists so a leaked key is recognisable in a log or a paste, and the separated prefix is what makes authentication one indexed lookup instead of a scan that verifies every stored hash in turn. The separator is a full stop because both halves are url-safe base64, whose alphabet already contains '-' and '_'. Secrets are hashed with the same plain SHA-256 as session tokens and for the same reason: 256 bits from a CSPRNG has no dictionary to attack, so scrypt on the hot path of every printer poll would cost 50 ms per request to buy nothing. Three restrictions are the substance of this, and each has a failure mode it exists to prevent: A key's level is capped against its owner's *current* level on every request rather than baked in at creation, so demoting a user weakens their outstanding keys immediately instead of leaving credentials in the wild that outrank the account they belong to. Verified against a running server: a key issued at manage drops to read the moment its owner is demoted, both in enforcement and in what the listing reports. A key never carries administrative rights, however senior its owner, so everything behind require_admin needs a session. require_user now refuses keys outright, which is what gates the key endpoints themselves. A credential that can mint another credential can never really be revoked -- whoever held a leaked key would issue themselves a fresh one ahead of the withdrawal. Keys skip CSRF, as cookies.py anticipated in phase 1. The check in require_level was already conditioned on PrincipalKind.USER, so this needed no code change; what it needed was the comment saying why a header that a cross-site page cannot set is not an ambient credential and has nothing to defend against. /metrics accepts a key in addition to SPOOLMAN_METRICS_TOKEN rather than instead of it. The shared token needs no database and no account, which is what an operator scraping their own instance wants and what keeps working when the database is down; a key is attributable and revocable through the interface, which is what you want when the scraper belongs to somebody else. Only keys are honoured there, never session cookies, so a signed-in browser tab does not quietly make the endpoint readable. Claude-Session: https://claude.ai/code/session_01J3YgMAnSapoXqS9iU8Xnmk
332 lines
12 KiB
Python
332 lines
12 KiB
Python
"""Main entrypoint to the server."""
|
|
|
|
import hmac
|
|
import logging
|
|
import subprocess
|
|
from logging.handlers import TimedRotatingFileHandler
|
|
from pathlib import Path
|
|
|
|
import uvicorn
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
from fastapi.responses import PlainTextResponse, RedirectResponse, Response
|
|
from prometheus_client import generate_latest
|
|
from scheduler.asyncio.scheduler import Scheduler
|
|
|
|
from spoolman import env, externaldb
|
|
from spoolman.api.v1.router import app as v1_app
|
|
from spoolman.auth import audit, secret
|
|
from spoolman.auth.levels import Level
|
|
from spoolman.client import SinglePageApplication
|
|
from spoolman.database import database
|
|
from spoolman.prometheus.metrics import registry
|
|
|
|
# Define a console logger
|
|
console_handler = logging.StreamHandler()
|
|
console_handler.setFormatter(logging.Formatter("%(name)-26s %(levelname)-8s %(message)s"))
|
|
|
|
# Setup the spoolman logger, which all spoolman modules will use
|
|
log_level = env.get_logging_level()
|
|
root_logger = logging.getLogger()
|
|
root_logger.setLevel(log_level)
|
|
root_logger.addHandler(console_handler)
|
|
|
|
# Fix uvicorn logging
|
|
logging.getLogger("uvicorn").setLevel(log_level)
|
|
if logging.getLogger("uvicorn").handlers:
|
|
logging.getLogger("uvicorn").removeHandler(logging.getLogger("uvicorn").handlers[0])
|
|
logging.getLogger("uvicorn").addHandler(console_handler)
|
|
|
|
logging.getLogger("uvicorn.error").setLevel(log_level)
|
|
|
|
access_handlers = logging.getLogger("uvicorn.access").handlers
|
|
if access_handlers:
|
|
logging.getLogger("uvicorn.access").setLevel(log_level)
|
|
logging.getLogger("uvicorn.access").removeHandler(access_handlers[0])
|
|
logging.getLogger("uvicorn.access").addHandler(console_handler)
|
|
|
|
# Get logger instance for this module
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Setup FastAPI
|
|
app = FastAPI(
|
|
debug=env.is_debug_mode(),
|
|
title="Spoolman",
|
|
version=env.get_version(),
|
|
)
|
|
app.add_middleware(GZipMiddleware)
|
|
app.mount(env.get_base_path() + "/api/v1", v1_app)
|
|
|
|
|
|
async def metrics_access_permitted(request: Request) -> bool:
|
|
"""Check whether a caller may read the metrics endpoint.
|
|
|
|
Metrics stay public while authentication is off, so existing Prometheus scrape
|
|
configs keep working untouched. With authentication on, a caller needs either the
|
|
shared SPOOLMAN_METRICS_TOKEN or an API key, unless the operator opts back out with
|
|
SPOOLMAN_METRICS_PUBLIC.
|
|
|
|
Both, rather than one or the other. The shared token is a deployment-level secret
|
|
that needs no database and no account, which is what an operator scraping their own
|
|
instance wants and what survives the database being down. An API key is attributable
|
|
and revocable through the interface, which is what you want when the scraper belongs
|
|
to somebody else. Neither is checked before the other for any security reason; the
|
|
token is simply free to check.
|
|
|
|
Only API keys are accepted, not session cookies, so that a signed-in browser tab
|
|
does not silently make the endpoint readable. See
|
|
:func:`spoolman.auth.dependencies.api_key_principal`.
|
|
|
|
Args:
|
|
request: The incoming request.
|
|
|
|
Returns:
|
|
bool: Whether to serve the metrics.
|
|
|
|
"""
|
|
if not env.is_auth_enabled() or env.is_metrics_public():
|
|
return True
|
|
|
|
presented = request.headers.get("x-api-key", "")
|
|
if not presented:
|
|
authorization = request.headers.get("authorization", "")
|
|
scheme, _, value = authorization.partition(" ")
|
|
if scheme.lower() == "bearer":
|
|
presented = value.strip()
|
|
if not presented:
|
|
return False
|
|
|
|
expected = env.get_metrics_token()
|
|
if expected and hmac.compare_digest(presented, expected):
|
|
return True
|
|
|
|
from spoolman.auth.dependencies import api_key_principal # noqa: PLC0415
|
|
|
|
principal = await api_key_principal(request)
|
|
return principal is not None and principal.covers(Level.READ)
|
|
|
|
|
|
# WA for prometheus /metrics bind with SinglePageApp at root
|
|
@app.get(
|
|
env.get_base_path() + "/metrics",
|
|
response_class=PlainTextResponse,
|
|
name="Get metrics for prometheus",
|
|
description=(
|
|
"Get app metrics for prometheusIf enabled SPOOLMAN_METRICS_ENABLED returned metrics by Spools and Filaments"
|
|
),
|
|
)
|
|
async def get_metrics(request: Request) -> Response:
|
|
"""Return prometheus metrics."""
|
|
if not await metrics_access_permitted(request):
|
|
return PlainTextResponse("Unauthorized", status_code=401)
|
|
return PlainTextResponse(generate_latest(registry))
|
|
|
|
|
|
base_path = env.get_base_path()
|
|
if base_path != "":
|
|
logger.info("Base path is: %s", base_path)
|
|
|
|
# If base path is set, add a redirect from non-slash suffix to slash
|
|
# suffix. Otherwise it won't work.
|
|
@app.get(base_path)
|
|
def root_redirect() -> Response:
|
|
"""Redirect to base path."""
|
|
return RedirectResponse(base_path + "/")
|
|
|
|
|
|
# Return a dynamic js config file
|
|
# This is so that the client side can access the base path variable.
|
|
@app.get(env.get_base_path() + "/config.js")
|
|
def get_configjs() -> Response:
|
|
"""Return a dynamic js config file."""
|
|
if '"' in base_path:
|
|
raise ValueError("Base path contains quotes, which are not allowed.")
|
|
|
|
return Response(
|
|
content=f"""
|
|
window.SPOOLMAN_BASE_PATH = "{base_path}";
|
|
""",
|
|
media_type="text/javascript",
|
|
)
|
|
|
|
|
|
# Mount the client side app. The new Svelte client is served by default; set
|
|
# SPOOLMAN_LEGACY_CLIENT to fall back to the old React client.
|
|
if env.is_legacy_client_enabled():
|
|
logger.info("Serving the legacy (React) client.")
|
|
app.mount(
|
|
base_path,
|
|
app=SinglePageApplication(
|
|
directory="client/dist",
|
|
base_path=env.get_base_path(),
|
|
fallback_document="index.html",
|
|
rewrite_asset_paths=True,
|
|
),
|
|
)
|
|
else:
|
|
app.mount(
|
|
base_path,
|
|
app=SinglePageApplication(
|
|
directory="client_v2/build",
|
|
base_path=env.get_base_path(),
|
|
fallback_document="200.html",
|
|
rewrite_asset_paths=False,
|
|
),
|
|
)
|
|
|
|
|
|
def add_cors_middleware() -> None:
|
|
"""Add CORS middleware to the FastAPI app based on environment settings."""
|
|
origins: list[str] = []
|
|
origin_regex: str | None = None
|
|
if env.is_debug_mode():
|
|
# Reflect whatever Origin asked, rather than sending a literal "*".
|
|
#
|
|
# allow_credentials=True below makes Starlette emit "*" verbatim, and browsers
|
|
# reject that outright for any request carrying credentials. Nothing noticed
|
|
# while the API was anonymous, but it silently breaks cookie-authenticated
|
|
# requests from the Vite dev server on port 5174 to the backend on 8000, which
|
|
# is the whole point of debug mode. Echoing the origin is what the wildcard was
|
|
# already trying to express.
|
|
logger.warning("Running in debug mode, reflecting all origins.")
|
|
origin_regex = ".*"
|
|
elif env.is_cors_defined():
|
|
cors_origins = env.get_cors_origin()
|
|
if cors_origins:
|
|
logger.info("CORS origins defined: %s", cors_origins)
|
|
origins = cors_origins
|
|
else:
|
|
logger.warning("CORS origins are not defined, no CORS will be applied.")
|
|
|
|
if not origins and origin_regex is None:
|
|
return
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=origins,
|
|
allow_origin_regex=origin_regex,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
expose_headers=["X-Total-Count"],
|
|
)
|
|
|
|
|
|
add_cors_middleware()
|
|
|
|
|
|
def log_auth_status() -> None:
|
|
"""Report the authentication configuration, and warn about combinations that surprise."""
|
|
if not env.is_auth_enabled():
|
|
return
|
|
|
|
from collections import Counter # noqa: PLC0415
|
|
|
|
from spoolman.api.v1.router import ROUTE_LEVELS # noqa: PLC0415
|
|
|
|
counts = Counter(str(level) for level in ROUTE_LEVELS.values())
|
|
logger.info(
|
|
"Authentication is enabled. %d API routes are gated (read: %d, edit: %d, manage: %d).",
|
|
len(ROUTE_LEVELS),
|
|
counts.get("read", 0),
|
|
counts.get("edit", 0),
|
|
counts.get("manage", 0),
|
|
)
|
|
|
|
if env.is_legacy_client_enabled():
|
|
# The React client has no sign-in screen and no cookie or CSRF handling, so it
|
|
# will fail every request with no way for the user to authenticate.
|
|
logger.warning(
|
|
"SPOOLMAN_LEGACY_CLIENT is set together with authentication. The legacy client "
|
|
"does not support signing in and will not work. Unset one of the two.",
|
|
)
|
|
|
|
if env.is_metrics_enabled() and not env.is_metrics_public() and not env.get_metrics_token():
|
|
logger.warning(
|
|
"Metrics are enabled but no SPOOLMAN_METRICS_TOKEN is set, so /metrics will "
|
|
"reject every scrape. Set a token, or set SPOOLMAN_METRICS_PUBLIC=TRUE.",
|
|
)
|
|
|
|
|
|
def add_file_logging() -> None:
|
|
"""Add file logging to the root logger."""
|
|
# Define a file logger with log rotation
|
|
log_file = env.get_logs_dir().joinpath("spoolman.log")
|
|
file_handler = TimedRotatingFileHandler(log_file, when="midnight", backupCount=5)
|
|
file_handler.setFormatter(logging.Formatter("%(asctime)s:%(levelname)s:%(message)s", "%Y-%m-%d %H:%M:%S"))
|
|
root_logger.addHandler(file_handler)
|
|
|
|
logging.getLogger("uvicorn").addHandler(file_handler)
|
|
access_handlers = logging.getLogger("uvicorn.access").handlers
|
|
if access_handlers:
|
|
logging.getLogger("uvicorn.access").addHandler(file_handler)
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def startup() -> None:
|
|
"""Run the service's startup sequence."""
|
|
# Check that the data directory is writable
|
|
env.check_write_permissions()
|
|
|
|
# Don't add file logging until we have verified that the data directory is writable
|
|
add_file_logging()
|
|
|
|
logger.info(
|
|
"Starting Spoolman v%s (commit: %s) (built: %s)",
|
|
app.version,
|
|
env.get_commit_hash(),
|
|
env.get_build_date(),
|
|
)
|
|
|
|
logger.info("Using data directory: %s", env.get_data_dir().resolve())
|
|
logger.info("Using logs directory: %s", env.get_logs_dir().resolve())
|
|
logger.info("Using backups directory: %s", env.get_backups_dir().resolve())
|
|
|
|
# Resolve the secret key before the database work. It needs a writable data
|
|
# directory but no schema, so failing here gives an actionable error ahead of the
|
|
# migration subprocess. Guarded so an auth-disabled instance never grows the file.
|
|
if env.is_auth_enabled():
|
|
secret.ensure_secret_key()
|
|
|
|
logger.info("Setting up database...")
|
|
database.setup_db(database.get_connection_url())
|
|
|
|
logger.info("Performing migrations...")
|
|
# Run alembic in a subprocess.
|
|
# There is some issue with the uvicorn worker that causes the process to hang when running alembic directly.
|
|
# See: https://github.com/sqlalchemy/alembic/discussions/1155
|
|
project_root = Path(__file__).parent.parent
|
|
subprocess.run(["alembic", "upgrade", "head"], check=True, cwd=project_root) # noqa: ASYNC221, S607
|
|
|
|
# Setup scheduler
|
|
schedule = Scheduler()
|
|
database.schedule_tasks(schedule)
|
|
externaldb.schedule_tasks(schedule)
|
|
audit.schedule_tasks(schedule)
|
|
|
|
log_auth_status()
|
|
|
|
logger.info("Startup complete.")
|
|
|
|
if env.is_docker() and not env.is_data_dir_mounted():
|
|
logger.warning("!!!! WARNING !!!!")
|
|
logger.warning("!!!! WARNING !!!!")
|
|
logger.warning("The data directory is not mounted.")
|
|
logger.warning(
|
|
'Spoolman stores its database in the container directory "%s". '
|
|
"If this directory isn't mounted to the host OS, the database will be lost when the container is stopped.",
|
|
env.get_data_dir(),
|
|
)
|
|
logger.warning(
|
|
"Please carefully read the docker part of the README.md file, "
|
|
"and ensure your docker-compose file matches the example.",
|
|
)
|
|
logger.warning("!!!! WARNING !!!!")
|
|
logger.warning("!!!! WARNING !!!!")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|