Rename the import package app to fluksio

A wheel whose top-level module is `app` collides with anything else in a
user's venv, so the package that is about to be published takes the name
it is published under. Only the Python package moves; the repo, the
Docker WORKDIR and the compose project keep theirs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 21:48:05 +02:00
co-authored by Claude Opus 5
parent 97785ee590
commit 60d7ec81c0
170 changed files with 629 additions and 619 deletions
+158
View File
@@ -0,0 +1,158 @@
"""What this installation knows about the portal it is enrolled with.
One file on the data volume, beside the OAuth keypair and for the same reason:
it is a credential, it must survive a rebuild, and it must never be in the
flows repository. Deleting it is the local, unilateral way to sever the
connection — the portal's tokens stop verifying immediately, whatever the
portal still believes.
The portal's public keys are stored here rather than fetched: they are pinned
at enrolment, when a person was holding a claim code they had just read off the
portal's own screen. A hub whose DNS or TLS is later hijacked cannot re-key an
installation that already enrolled; it can only fail to verify.
"""
from __future__ import annotations
import json
import logging
import os
import secrets
from dataclasses import dataclass
from typing import Any
import jwt
from jwt.exceptions import InvalidTokenError
from fluksio.core.config import settings
logger = logging.getLogger(__name__)
#: Marks a request the connector replayed off the tunnel. The value is minted
#: per process and never leaves it, because the header itself is not evidence:
#: anything that can reach this API directly can set one, and the difference
#: decides whether a pairing device is handed a credential the whole internet
#: can present. The connector overwrites it on every frame, so a browser
#: sending its own gets nowhere from either direction.
VIA_HEADER = "x-fluksio-via"
VIA_PORTAL = secrets.token_urlsafe(16)
@dataclass(frozen=True)
class CloudConfig:
portal_url: str
ws_url: str
installation_id: str
token: str
issuer: str
jwks: dict[str, Any]
#: The local account this installation acts as on its own behalf: what the
#: health summary is collected as, and what a screen paired through the
#: portal borrows for want of a person. Recorded at enrolment from whoever
#: performed it. Portal *sessions* no longer come through here — they name
#: a person, and are resolved to the local account mapped to them.
local_user_id: str
enrolled_at: str
portal_account: str | None = None
_cache: tuple[float, CloudConfig | None] | None = None
def _read() -> CloudConfig | None:
"""Load the config, re-reading only when the file has changed."""
global _cache
path = settings.CLOUD_CONFIG_FILE
try:
mtime = path.stat().st_mtime
except OSError:
_cache = (0.0, None)
return None
if _cache is not None and _cache[0] == mtime:
return _cache[1]
try:
data = json.loads(path.read_text())
config = CloudConfig(**data)
except (OSError, ValueError, TypeError):
logger.exception("Could not read %s; remote access stays off", path)
_cache = (mtime, None)
return None
_cache = (mtime, config)
return config
def load() -> CloudConfig | None:
return _read()
def exists() -> bool:
return settings.CLOUD_CONFIG_FILE.exists()
def save(config: CloudConfig) -> None:
path = settings.CLOUD_CONFIG_FILE
path.parent.mkdir(parents=True, exist_ok=True)
# Written through a temporary file so a crash mid-write cannot leave a
# half-parsed credential behind, and never group- or world-readable.
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(config.__dict__, indent=2))
os.chmod(tmp, 0o600)
tmp.replace(path)
global _cache
_cache = None
def delete() -> None:
settings.CLOUD_CONFIG_FILE.unlink(missing_ok=True)
global _cache
_cache = None
def decode_portal_token(token: str) -> dict[str, Any]:
"""Validate a token the portal minted for this installation.
Raises ``InvalidTokenError`` for everything else, including the ordinary
case of not being enrolled at all — which is what makes this branch free
for an installation nobody connected.
"""
config = _read()
if config is None:
raise InvalidTokenError("this installation is not enrolled with a portal")
try:
key = jwt.PyJWKSet.from_dict(config.jwks).keys[0]
except (IndexError, jwt.PyJWKError) as exc:
raise InvalidTokenError(f"the pinned portal key is unusable: {exc}") from exc
claims: dict[str, Any] = jwt.decode(
token,
key,
algorithms=["RS256"],
# The audience is this installation's own id, so a token the portal
# minted for somebody else's machine fails here.
audience=config.installation_id,
issuer=config.issuer,
)
scope = claims.get("scope")
if scope == "panel":
# A screen that paired through the portal. The portal named the panel
# and nothing else; what that panel may read is decided here, by the
# same check a panel paired on this network passes. It acts as the
# enrolling account for want of any other, but the scope check is what
# actually bounds it — so a token of this scope that names no panel is
# refused rather than left holding the account it borrows.
panel = str(claims.get("sub") or "")
if not panel:
raise InvalidTokenError("a panel token must name its panel")
return {"sub": config.local_user_id, "panel": panel}
if scope != "proxy":
raise InvalidTokenError("not a proxy token")
# A portal session names the person holding it, and that name is the whole
# of their identity here: the caller resolves it to the local account it
# was mapped to, and a portal identity nobody mapped resolves to nothing.
# Deliberately no local account by default — the failure of a mapping must
# be a refusal, not a fallback onto whoever enrolled.
portal_sub = str(claims.get("sub") or "")
if not portal_sub:
raise InvalidTokenError("a proxy token must name its user")
return {"portal_sub": portal_sub}