Files
Juan DenisandClaude Opus 5 bc10c51a16 feat(connect): keep the connect client current from the signed feed
On reconnect the client asks ServerKit Cloud what the current release is and
verifies the answer against the JWKS it already trusts before believing it —
the artefact lives on a CDN we do not control, so the signature is the only
reason to trust the URL in it.

- services/connect_updates.py  fetch, verify, compare, and act: an install
  the panel can update runs its own update path; one it cannot (Docker,
  non-root) says exactly what to run on the host instead
- services/connect_client.py   ask once per reconnect, at most hourly, and
  never let a failed check take the connection down with it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018q56AfqpgKjio4n9KYubvZ
2026-09-02 12:03:11 -04:00

207 lines
7.4 KiB
Python

"""Keeping the ServerKit Cloud connect client current.
On every reconnect the client asks ServerKit Cloud what the current release is. The
answer is signed with ServerKit Cloud's Ed25519 key, and this module verifies it against
the JWKS the panel already trusts before believing a word of it: the artefact
lives on a CDN we do not control, and an unsigned feed would be a way to hand
a customer's server any version at all.
What happens when we are behind depends on what this install can do:
- an install the panel can update itself (the normal case) runs the panel's
own update path, exactly as an operator pressing Update would;
- an install it cannot (Docker, non-root) records that and shows the
operator what to run on the host. It is never guessed at.
Nothing here restarts anything on its own: the panel's updater owns that, and
the relay client reconnects when the process comes back.
"""
import json
import logging
import time
import requests
logger = logging.getLogger(__name__)
FEED_TIMEOUT_S = 10
# How often to ask, at most. A release is not urgent enough to poll harder.
CHECK_INTERVAL_S = 3600
COMPONENT = 'connect-client'
class UpdateCheck:
"""Remembers when it last asked and what it found."""
def __init__(self):
self.last_checked_at = 0.0
self.available = None # version string ServerKit Cloud published
self.state = 'unknown' # current | behind | updating | unsupported | error
self.detail = None
def to_dict(self) -> dict:
return {
'state': self.state,
'available': self.available,
'detail': self.detail,
'last_checked_at': self.last_checked_at or None,
}
def due(self, now=None) -> bool:
now = now if now is not None else time.monotonic()
return now - self.last_checked_at >= CHECK_INTERVAL_S
# ==================== the signed feed ====================
def fetch_feed(cloud_url: str, component: str = COMPONENT, channel: str = 'stable') -> dict | None:
try:
res = requests.get(f'{cloud_url}/api/releases/{component}',
params={'channel': channel}, timeout=FEED_TIMEOUT_S)
res.raise_for_status()
return res.json()
except Exception as exc:
logger.debug('Connect updates: could not read the release feed: %s', exc)
return None
def fetch_jwks(cloud_url: str) -> dict | None:
try:
res = requests.get(f'{cloud_url}/api/.well-known/jwks.json', timeout=FEED_TIMEOUT_S)
res.raise_for_status()
return res.json()
except Exception as exc:
logger.debug('Connect updates: could not read the JWKS: %s', exc)
return None
def verify_feed(feed: dict, jwks: dict) -> dict | None:
"""Return the signed payload, or None if the signature does not hold.
The signature covers the canonical JSON of the payload as ServerKit Cloud built it,
so we rebuild exactly those bytes and compare rather than trusting the
parsed object.
"""
signature = (feed or {}).get('signature')
if not signature or not jwks:
return None
try:
import base64
import jwt
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
header = jwt.get_unverified_header(signature)
kid = header.get('kid')
key = next((k for k in (jwks.get('keys') or []) if k.get('kid') == kid), None)
if key is None or key.get('crv') != 'Ed25519':
return None
raw = base64.urlsafe_b64decode(key['x'] + '=' * (-len(key['x']) % 4))
claims = jwt.decode(signature, Ed25519PublicKey.from_public_bytes(raw),
algorithms=['EdDSA'])
except Exception as exc:
logger.warning('Connect updates: release feed signature did not verify (%s)', exc)
return None
signed_body = claims.get('feed')
if not signed_body:
return None
payload = {k: v for k, v in feed.items()
if k in ('component', 'channel', 'current', 'generated_at')}
rebuilt = json.dumps(payload, sort_keys=True, separators=(',', ':'))
if rebuilt != signed_body:
logger.warning('Connect updates: the release feed does not match its signature')
return None
return payload
# ==================== deciding and acting ====================
def _version_tuple(value):
import re
return tuple(int(n) for n in re.findall(r'\d+', str(value or ''))[:4])
def is_behind(running: str, available: str) -> bool:
if not available:
return False
if not running:
return True
return _version_tuple(running) < _version_tuple(available)
def check(cloud_url: str, running_version: str, state: UpdateCheck) -> UpdateCheck:
"""Ask, verify, and record. Does not act."""
state.last_checked_at = time.monotonic()
feed = fetch_feed(cloud_url)
if feed is None:
state.state = 'error'
state.detail = 'Could not reach the release feed'
return state
payload = verify_feed(feed, fetch_jwks(cloud_url))
if payload is None:
state.state = 'error'
state.detail = 'The release feed was not signed by ServerKit Cloud'
return state
current = payload.get('current') or {}
state.available = current.get('version')
if is_behind(running_version, state.available):
state.state = 'behind'
state.detail = f'{state.available} is available (running {running_version})'
else:
state.state = 'current'
state.detail = None
return state
def apply_if_behind(state: UpdateCheck) -> UpdateCheck:
"""Run the panel's own update path when we are behind and able.
The connect client ships inside the panel, so updating it is updating the
panel — the same code path an operator uses, with the same refusals.
"""
if state.state != 'behind':
return state
try:
from app.services import panel_update_service
except Exception as exc:
state.state = 'unsupported'
state.detail = f'This install cannot update itself from here ({exc})'
return state
try:
capability = panel_update_service.get_capability()
except Exception as exc:
state.state = 'error'
state.detail = f'Could not check whether this install can update itself: {exc}'
return state
supported = capability.get('supported') if isinstance(capability, dict) else bool(capability)
if not supported:
reason = (capability.get('reason') if isinstance(capability, dict) else None) or 'unsupported install'
state.state = 'unsupported'
state.detail = (f'ServerKit Cloud has {state.available}, but this install cannot update '
f'itself ({reason}). Update it from the host and the connect client '
f'comes with it.')
logger.info('Connect updates: %s', state.detail)
return state
logger.info('Connect updates: updating the panel to %s for the connect client', state.available)
try:
panel_update_service.start_update()
state.state = 'updating'
state.detail = f'Updating to {state.available}'
except Exception as exc:
state.state = 'error'
state.detail = f'The update did not start: {exc}'
logger.warning('Connect updates: %s', state.detail)
return state
def check_and_apply(cloud_url: str, running_version: str, state: UpdateCheck) -> UpdateCheck:
if not state.due():
return state
return apply_if_behind(check(cloud_url, running_version, state))