A screen somewhere this installation is not reachable from asks the portal for a code instead, and the portal mints its credential — because a token signed here is one such a device could never present. Where it was minted changes nothing about what it may do. The panel gate moved off the branch that decodes a local panel token and onto whatever claims name a panel, so the portal's and this installation's are bounded by the same check against the same panel's dashboards. A token of that scope naming no panel is refused rather than left holding the account it borrows. The connector marks what arrives on its socket, since that is the only thing that makes it true, and the approval screen now names what is holding a code — approving adopts whatever answers, so it is worth a look first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017F9RnYCJgASuBTcAjxmnsp
147 lines
5.0 KiB
Python
147 lines
5.0 KiB
Python
"""Connecting this installation to a Fluksio portal, and cutting it loose.
|
|
|
|
Entirely optional, and superuser-only to change: enrolling grants a remote
|
|
party the rights of the account that performed it, which is not a decision an
|
|
ordinary user of this installation gets to make on everyone else's behalf.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.api.deps import CurrentUser, get_current_active_superuser, get_current_user
|
|
from app.cloud import config as cloud_config
|
|
from app.models import Message
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/cloud", tags=["cloud"])
|
|
|
|
|
|
class EnrollBody(BaseModel):
|
|
#: Where the portal's API lives, e.g. https://hub.fluksio.com
|
|
portal_url: str = Field(min_length=1, max_length=255)
|
|
claim_code: str = Field(min_length=1, max_length=32)
|
|
|
|
|
|
@router.get("/status", dependencies=[Depends(get_current_user)])
|
|
def read_status(request: Request) -> dict[str, Any]:
|
|
"""Whether this installation is enrolled, and whether the link is up.
|
|
|
|
Readable by any signed-in user: everyone here has a right to know whether
|
|
the machine they are using can be reached from outside.
|
|
"""
|
|
connector = getattr(request.app.state, "cloud_connector", None)
|
|
if connector is None:
|
|
config = cloud_config.load()
|
|
return {
|
|
"enrolled": config is not None,
|
|
"connected": False,
|
|
"portal_url": config.portal_url if config else None,
|
|
"issuer": config.issuer if config else None,
|
|
"portal_account": config.portal_account if config else None,
|
|
"installation_id": config.installation_id if config else None,
|
|
"last_error": None,
|
|
"connected_since": None,
|
|
}
|
|
status: dict[str, Any] = connector.status()
|
|
return status
|
|
|
|
|
|
@router.post(
|
|
"/enroll",
|
|
dependencies=[Depends(get_current_active_superuser)],
|
|
response_model=Message,
|
|
)
|
|
async def enroll(
|
|
request: Request, current_user: CurrentUser, body: EnrollBody
|
|
) -> Message:
|
|
"""Redeem a claim code and start dialling the portal.
|
|
|
|
The account performing this is recorded as the one every portal session
|
|
will act as. There is no way to widen that later from the portal side.
|
|
"""
|
|
if cloud_config.exists():
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="This installation is already connected to a portal",
|
|
)
|
|
|
|
base = body.portal_url.rstrip("/")
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
response = await client.post(
|
|
f"{base}/api/v1/enroll/",
|
|
json={"claim_code": body.claim_code, "app_version": "0.1.0"},
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
raise HTTPException(
|
|
status_code=502, detail=f"Could not reach the portal: {exc}"
|
|
) from exc
|
|
if response.status_code == 404:
|
|
raise HTTPException(
|
|
status_code=400, detail="That claim code is unknown or has expired"
|
|
)
|
|
if response.status_code != 200:
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"The portal refused the claim ({response.status_code})",
|
|
)
|
|
|
|
data = response.json()
|
|
config = cloud_config.CloudConfig(
|
|
portal_url=base,
|
|
ws_url=data["ws_url"],
|
|
installation_id=data["installation_id"],
|
|
token=data["installation_token"],
|
|
issuer=data["issuer"],
|
|
# Pinned here, at the one moment the claim code proves who we are
|
|
# talking to. Nothing refreshes this.
|
|
jwks=data["jwks"],
|
|
local_user_id=str(current_user.id),
|
|
enrolled_at=datetime.now(timezone.utc).isoformat(),
|
|
portal_account=current_user.email,
|
|
)
|
|
cloud_config.save(config)
|
|
_start_connector(request.app)
|
|
return Message(message="Connected to the portal")
|
|
|
|
|
|
@router.delete(
|
|
"", dependencies=[Depends(get_current_active_superuser)], response_model=Message
|
|
)
|
|
def disconnect(request: Request) -> Message:
|
|
"""Sever the connection from this side.
|
|
|
|
Unilateral and immediate: the config is the only reason portal tokens
|
|
verify here, so deleting it ends remote access whatever the portal still
|
|
has on file.
|
|
"""
|
|
task = getattr(request.app.state, "cloud_task", None)
|
|
if task is not None:
|
|
task.cancel()
|
|
request.app.state.cloud_task = None
|
|
request.app.state.cloud_connector = None
|
|
cloud_config.delete()
|
|
return Message(message="Disconnected from the portal")
|
|
|
|
|
|
def _start_connector(app: Any) -> None:
|
|
from app.cloud.connector import CloudConnector
|
|
|
|
existing = getattr(app.state, "cloud_task", None)
|
|
if existing is not None:
|
|
existing.cancel()
|
|
connector = CloudConnector(app)
|
|
app.state.cloud_connector = connector
|
|
app.state.cloud_task = asyncio.create_task(
|
|
connector.serve_forever(), name="cloud-connector"
|
|
)
|