- POST /devices/pair answers a 2-character `check`, listed by /devices/pending and shown beside each waiting entry; five codes a minute per address, and at 50 waiting the oldest gives way instead of a 429 - portal-minted device tokens are asked for with the device's nonce (`pnc`) and held against it, so unpair and delete revoke them; one without `pnc` stands only while the nonce is 0 - a device credential acts as the device (`device_principal`), not as the approving superuser, so deleting that account unpairs nothing - the report is validated port by port: unknown enum values read as `unknown`, unreadable ports and sections are left out and logged, `config` may name an entry of `config_schemas`; 422 only for no schema 1 or no object - devices.json is parsed once per change (cached by stat), delete drops the report and keeps the nonce, a busy recheck runs in the background - scope and io only from enabled flows (start/stop tells the devices), a port bound twice gives one io row - the Devices tab's saves cancel and hold back device refetches until settled
188 lines
7.0 KiB
Python
188 lines
7.0 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 per request. They
|
|
are pinned at enrolment, when a person was holding a claim code they had just
|
|
read off the portal's own screen, and replaced by the set the hub sends each
|
|
time the tunnel attaches, so the hub can publish a new key beside the old one.
|
|
That makes the tunnel's TLS what a later key rests on: only a hub that answers
|
|
at the enrolled address, to this instance's own credential, can hand it one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import secrets
|
|
from collections.abc import Mapping
|
|
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 via_portal(headers: Mapping[str, str]) -> bool:
|
|
"""Whether the connector replayed this request off the tunnel."""
|
|
return secrets.compare_digest(headers.get(VIA_HEADER, ""), VIA_PORTAL)
|
|
|
|
|
|
def portal_base(headers: Mapping[str, str]) -> str | None:
|
|
"""Where this instance answers at the hub, for a request that came through it.
|
|
|
|
The hub serves an instance under ``/i/{id}`` on the host it signs as, the
|
|
issuer enrolment pinned. ``None`` for a request made here.
|
|
"""
|
|
config = _read()
|
|
if config is None or not via_portal(headers):
|
|
return None
|
|
return f"{config.issuer.rstrip('/')}/i/{config.instance_id}"
|
|
|
|
|
|
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")
|
|
|
|
# By `kid`, so the hub can publish the next key beside the current one and
|
|
# tokens signed with either verify while it rotates.
|
|
kid = jwt.get_unverified_header(token).get("kid")
|
|
try:
|
|
keys = jwt.PyJWKSet.from_dict(config.jwks).keys
|
|
except (jwt.PyJWKError, jwt.PyJWKSetError) as exc:
|
|
raise InvalidTokenError(f"the pinned portal keys are unusable: {exc}") from exc
|
|
key = next((candidate for candidate in keys if candidate.key_id == kid), None)
|
|
if key is None:
|
|
raise InvalidTokenError("signed with a key the portal has not published")
|
|
|
|
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 the nonce this instance gave it when asking, nothing else; what
|
|
# that panel may read is decided here, by the same checks a panel
|
|
# paired on this network passes, the nonce included. A token of this
|
|
# scope that names no panel is refused.
|
|
panel = str(claims.get("sub") or "")
|
|
if not panel:
|
|
raise InvalidTokenError("a panel token must name its panel")
|
|
return {"panel": panel, "pnc": claims.get("pnc")}
|
|
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}
|