In Part 1, I described the loopback token between portald and each package daemon as an HMAC-signed request — a timestamp and signature checked on every call. That's not what's in the codebase, and it's worth correcting out loud rather than quietly, because the two designs were an actual choice, not a typo.
What's actually there is simpler: a 32-byte secrets.token_urlsafe value, minted once when a package is installed, written to that package's root-owned EnvironmentFile, and compared with hmac.compare_digest — constant-time, so a timing attack can't recover it byte by byte — on every request. No signature, no timestamp, no replay window to reason about. It's strictly weaker than a Unix socket, where the kernel enforces the boundary and there's no secret to leak at all. It's the cost of using loopback ports instead of sockets, and the token exists specifically so that a compromised tenant site sharing the same Pi can't call a package's API directly just because the port is reachable from any local process.
Two authentication dependencies, on purpose
The panel itself is single-admin — holding a valid session is the entire authorization model, there's no role to check and no ownership to compare. That's a deliberate simplification: an earlier version asked the same "is this allowed" question and answered it in thirty-six separate branches scattered through the codebase. Collapsing every one of those checks into "do you have a session" removed an entire category of authorization bugs by making them impossible to write.
Sessions are checked two different ways, though, and the difference is the interesting part. Ordinary API calls go through current_user, which reads a cookie or an Authorization header and nothing else. Server-Sent Events routes go through stream_user, which additionally accepts ?token= in the URL. That's not laziness — EventSource can't set request headers, so a streaming client genuinely has no other way to authenticate. But a session token sitting in a URL leaks somewhere a cookie never does: nginx access logs, browser history, the Referer header on the next navigation. Making that acceptable everywhere just because streaming needs it would hand the weakest option to every route, including the ones that already have a perfectly good cookie. So allow_query is opt-in per route rather than inherited, and streaming responses carry Referrer-Policy: no-referrer specifically so the token in the URL can't ride along when the operator clicks somewhere else.
The proxy layer follows the same logic at the URL level. Package routes are split into /cpanelapi/pkg/{package}/{path} for ordinary calls and /cpanelapi/stream/{package}/{path} for SSE, rather than one route with a ?stream=1 switch. A single route would mean the authentication mode depended on a query parameter the caller controls — appending ?stream=1 to any request would silently opt it into query-token auth. Two static paths mean the auth dependency is fixed per route at definition time, not decided by whatever the request happens to contain.
@router.api_route("/cpanelapi/pkg/{package}/{path:path}", methods=PROXY_METHODS)
async def forward(package: str, request: Request, path: str = "",
username: str = Depends(deps.current_user)):
...
@router.api_route("/cpanelapi/stream/{package}/{path:path}", methods=PROXY_METHODS)
async def forward_stream(package: str, request: Request, path: str = "",
username: str = Depends(deps.stream_user)):
...
Neither handler knows what any package actually does — both just resolve the package name to a port and token, and forward. Installing package eighteen adds routes without adding a line of routing code.
Audit logging that doesn't drown itself
Every routed call still writes to the audit log, but not every one of them — only mutations, and any call that failed:
def _should_audit(method: str, status_code: int) -> bool:
return method != "GET" or status_code >= 400
A successful GET is a read. Recording one per page load would bury the mutations an operator actually needs to find under noise, which defeats the point of having an audit log at all. Each recorded row also carries a correlation ID generated per request, so a single operator action that fans out into several proxied calls — install a package, which restarts a service, which touches DNS — reads back as one traceable unit instead of scattered unrelated rows. And the request body is deliberately never recorded: portald doesn't parse what a package's payload means, so it has no way to know which of its fields are secrets. The package logs its own audit detail; the proxy layer only records the routing fact.
Where the marketplace catalog actually lands
Part 1 covered the shape of the package marketplace. The fetch itself tries two strategies in order, because relying on one GitHub endpoint staying reachable and un-rate-limited is optimistic:
async def _fetch_remote_catalog(db_path=None):
token = github_auth.get_github_token(db_path)
if token:
# GitHub Contents API — handles private repos, classic and fine-grained PATs
resp = await client.get(f"{api_url}?ref=master&_t={cache_bust}",
headers={"Accept": "application/vnd.github.raw",
"Authorization": f"Bearer {token}"})
if resp.status_code == 200:
return resp.json(), True
# raw.githubusercontent.com — works unauthenticated for a public repo
resp = await client.get(f"{raw_url}?_t={cache_bust}")
...
Every successful fetch is cached to disk immediately, so a catalog refresh that fails — no network, GitHub down, rate limit — degrades to whatever was fetched last rather than an empty marketplace page. The _t cache-busting parameter and the explicit Cache-Control: no-cache headers exist because CDN-fronted raw content has bitten me before: a package bump that doesn't show up in the catalog for twenty minutes because a CDN edge is still serving the old file is a bug I'd rather not have to explain.
The ops boundary hasn't loosened
None of the above matters if the layer underneath it can be bypassed, so the boundary from the original post is still enforced exactly as strictly: package services hold no state and never open the database — portald is the only process that touches hostpanel.db — and when a package needs to do something privileged, it shells out to its own ops/hp-<pkg> script through a single, narrowly scoped sudoers line.
That script assumes its caller is hostile, on principle, because anyone who is the hp-<pkg> service user can reach it, not only the service itself. Every invocation validates in a fixed order — arity, then identifier, then a deny-list, then the remaining arguments, then preconditions — before it acts. Secrets travel on stdin rather than as command-line arguments, because /proc/<pid>/cmdline is world-readable on Linux, and a password or API key passed as an argv value is visible to any other local process for as long as that process runs.
Panel Settings drew the same ownership line the rest of the system does, which is the detail I'd have gotten wrong without writing it down: colour mode and sidebar width live in localStorage, because they're a property of the screen, not the account — the same login is used from a laptop and a wall-mounted display, and they should look different. Version, bind address, DB path, and port range come from GET /cpanelapi/system/panel instead of being baked into the frontend bundle, because a literal in the bundle is a guess that goes stale the moment an environment variable changes. That endpoint is authenticated and deliberately partial — secret_key never appears in the response, because it signs session cookies and leaking it would let any reader mint a valid admin session. There's a test that asserts the key doesn't show up in the body under any field name, specifically so a future refactor can't reintroduce it by accident.
What's next in Part 3
With the token model, the audit trail, and the marketplace fetch corrected and nailed down, the next post covers the edge: dynamic Nginx server-block generation, ACME certificates through both webroot and PowerDNS's RFC2136 challenge, and the security package's UFW rules that keep the private zone off the public IP entirely.
Repository: Developer-Geekay/hostpanel