feat: implement update-service v1 migration and enrollment flow

- added /update/enroll endpoint and enrollment logic
- migrated update client to v1 api endpoints and bearer auth
- implemented remote status reporting in backend and scripts
- updated requirements and project status
This commit is contained in:
2025-12-31 00:05:46 +01:00
parent fde2825112
commit 47290d2d8f
41 changed files with 1384 additions and 19 deletions

View File

@ -19,6 +19,8 @@ from backend.auth import (
from backend.models import (
ActionRequest,
ActionResponse,
EnrollRequest,
EnrollResponse,
LoginRequest,
LoginResponse,
UpdateActionResponse,
@ -239,6 +241,24 @@ def update_status(settings: Settings = Depends(get_settings)) -> UpdateStatus:
return UpdateStatus(**status_data)
@app.post("/update/enroll", response_model=EnrollResponse, dependencies=[Depends(get_current_admin)])
def update_enroll(
payload: EnrollRequest | None = Body(default=None),
settings: Settings = Depends(get_settings),
) -> EnrollResponse:
if payload and payload.enroll_token:
settings.update_enroll_token = payload.enroll_token
try:
update.enroll(settings)
return EnrollResponse(enrolled=True, message="Enrollment successful")
except Exception as exc:
logger.exception("Enrollment failed")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Enrollment failed: {str(exc)}",
) from exc
@app.post("/update/check", response_model=UpdateCheckResponse, dependencies=[Depends(get_current_admin)])
def update_check(settings: Settings = Depends(get_settings)) -> UpdateCheckResponse:
try:

View File

@ -41,6 +41,16 @@ class UpdateStatus(BaseModel):
last_status: str
last_error: Optional[str] = None
last_timestamp: Optional[str] = None
enrolled: bool = False
class EnrollRequest(BaseModel):
enroll_token: Optional[str] = None
class EnrollResponse(BaseModel):
enrolled: bool
message: str
class UpdateCheckResponse(BaseModel):

View File

@ -40,7 +40,13 @@ class Settings:
self.notify_timeout: int = int(os.getenv("SKD_NOTIFY_TIMEOUT", "5"))
self.dry_run: bool = os.getenv("SKD_DRY_RUN", "false").lower() == "true"
self.update_url: str = os.getenv("SKD_UPDATE_URL", "https://update.wlkns.org")
self.update_token: str = os.getenv("SKD_UPDATE_TOKEN", "")
self.update_service_url: str = os.getenv("SKD_UPDATE_SERVICE_URL", "https://update.wlkns.org")
self.update_project_id: str = os.getenv("SKD_UPDATE_PROJECT_ID", "safe-kiddo-control")
self.update_enroll_token: str = os.getenv("SKD_UPDATE_ENROLL_TOKEN", "")
self.update_token_file: str = os.getenv(
"SKD_UPDATE_TOKEN_FILE", "/var/lib/skd/update_token"
)
self.update_token: str = self._load_update_token()
self.update_interval: int = int(os.getenv("SKD_UPDATE_INTERVAL", "3600"))
self.update_status_url: str = os.getenv(
"SKD_UPDATE_STATUS_URL", "https://update.wlkns.org/status"
@ -63,6 +69,18 @@ class Settings:
def _parse_list(value: str) -> List[str]:
return [item for item in (part.strip() for part in value.split(",")) if item]
def _load_update_token(self) -> str:
env_token = os.getenv("SKD_UPDATE_TOKEN", "")
if env_token:
return env_token
if os.path.exists(self.update_token_file):
try:
with open(self.update_token_file, "r", encoding="utf-8") as f:
return f.read().strip()
except OSError:
pass
return ""
@lru_cache(maxsize=1)
def get_settings() -> Settings:

View File

@ -70,20 +70,56 @@ def get_status(settings: Settings) -> Dict[str, Any]:
"last_status": data.get("status", "unknown"),
"last_error": data.get("error") or None,
"last_timestamp": data.get("timestamp"),
"enrolled": bool(settings.update_token),
}
def enroll(settings: Settings) -> str:
if not settings.update_enroll_token:
raise ValueError("No enrollment token provided in settings")
enroll_url = f"{settings.update_service_url}/v1/enroll"
payload = {
"project_id": settings.update_project_id,
"client_id": os.uname().nodename,
"software_id": "safe-kiddo",
"enroll_token": settings.update_enroll_token,
}
with httpx.Client(timeout=10.0) as client:
response = client.post(enroll_url, json=payload)
response.raise_for_status()
data = response.json()
token = data.get("token")
if not token:
raise ValueError("Enrollment response did not contain a token")
# Save token
token_path = Path(settings.update_token_file)
_ensure_parent(token_path)
token_path.write_text(token, encoding="utf-8")
# Update settings object for immediate use
settings.update_token = token
return token
def _parse_version(value: str) -> List[int]:
return [int(part) for part in value.split(".")]
def check_update(settings: Settings) -> Dict[str, Any]:
headers = {}
if settings.update_token:
headers["Authorization"] = f"Bearer {settings.update_token}"
if not settings.update_token:
raise ValueError("Client is not enrolled (missing update token)")
headers = {"Authorization": f"Bearer {settings.update_token}"}
manifest_url = (
f"{settings.update_service_url}/v1/projects/{settings.update_project_id}/manifest"
)
with httpx.Client(timeout=10.0) as client:
response = client.get(settings.update_url, headers=headers)
response = client.get(manifest_url, headers=headers)
response.raise_for_status()
manifest = response.json()
@ -109,21 +145,65 @@ def check_update(settings: Settings) -> Dict[str, Any]:
}
def report_status(
settings: Settings,
status: str,
version: str,
error: str | None = None,
duration_ms: int | None = None,
) -> None:
if not settings.update_token:
return
report_url = (
f"{settings.update_service_url}/v1/projects/{settings.update_project_id}/status"
)
payload = {
"project_id": settings.update_project_id,
"version": version,
"status": status,
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"client_id": os.uname().nodename,
}
if error:
payload["error_code"] = error
payload["reason"] = error
if duration_ms is not None:
payload["duration_ms"] = duration_ms
try:
headers = {"Authorization": f"Bearer {settings.update_token}"}
with httpx.Client(timeout=10.0) as client:
client.post(report_url, json=payload, headers=headers).raise_for_status()
except Exception:
# We don't want to crash if status reporting fails
pass
def _run_async(script_path: Path, settings: Settings) -> None:
env = os.environ.copy()
env["SKD_UPDATE_SERVICE_URL"] = settings.update_service_url
env["SKD_UPDATE_PROJECT_ID"] = settings.update_project_id
env["SKD_UPDATE_TOKEN"] = settings.update_token
env["SKD_UPDATE_STATUS_FILE"] = settings.update_status_file
env["SKD_UPDATE_LOG_FILE"] = settings.update_log_file
subprocess.Popen([str(script_path)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.Popen(
[str(script_path)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
def start_update(settings: Settings, version: str | None = None) -> None:
_write_status(settings, "in_progress", version or _read_version())
current_version = version or _read_version()
_write_status(settings, "in_progress", current_version)
report_status(settings, "in_progress", current_version)
script = _project_root() / "scripts" / "update_client.sh"
_run_async(script, settings)
def start_rollback(settings: Settings) -> None:
_write_status(settings, "in_progress", _read_version())
current_version = _read_version()
_write_status(settings, "in_progress", current_version)
report_status(settings, "in_progress", current_version)
script = _project_root() / "scripts" / "rollback_client.sh"
_run_async(script, settings)