Files
stroblmeandClaude Opus 5 d01a8dad37 Rename Installation to Instance
Follows the portal: the noun is "instance" everywhere the app says it —
UI strings, CLI output, error details, docs and comments. The wire keys
(`instance_id`, `instance_token`) and the hub route this calls move with it.

An existing cloud.json is adopted rather than refused: without the key
alias the dataclass fails to parse, which the caller swallows and reads as
"never enrolled" instead of "reconnect".

`instance_key` on a node type becomes `target_key`. It means the outside
thing a node points at, which is a different sense of the word, and keeping
both would put two meanings of "instance" in one codebase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
2026-08-31 10:12:01 +02:00

163 lines
5.9 KiB
Python

"""What this instance 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
instance 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
instance_id: str
token: str
issuer: str
jwks: dict[str, Any]
#: The local account this instance 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())
# A file written before the installation/instance rename. Adopt it
# rather than failing to parse, which would read as "never enrolled".
if "installation_id" in data:
data["instance_id"] = data.pop("installation_id")
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 instance.
Raises ``InvalidTokenError`` for everything else, including the ordinary
case of not being enrolled at all — which is what makes this branch free
for an instance nobody connected.
"""
config = _read()
if config is None:
raise InvalidTokenError("this instance 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 instance's own id, so a token the portal
# minted for somebody else's machine fails here.
audience=config.instance_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}