diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e2d2d9..c593dac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,24 @@ By: Codex (GPT-5) | 28.12.2025 | 🏗️ Planning | ID: Update-Format festgelegt (JSON + tar.gz). By: Codex (GPT-5) | | 28.12.2025 | 🏗️ Planning | ID: Update-Service URL auf update.wlkns.org festgelegt. By: Codex (GPT-5) | | 28.12.2025 | 🏗️ Planning | ID: EPIC_000009 Update Webservice (External Team) dokumentiert. By: Codex (GPT-5) | +| 28.12.2025 | 🏗️ Planning | ID: TASK_000027-TASK_000029 fuer EPIC_000008 ausgearbeitet. By: Codex (GPT-5) | +| 28.12.2025 | ⚙️ Code | ID: Update-Config Keys in Settings/ENV/README definiert. By: Codex (GPT-5) | +| 28.12.2025 | 📝 Req | ID: Update-Flow fuer Client dokumentiert. By: Codex (GPT-5) | +| 28.12.2025 | 📝 Req | ID: Update-Flow Prototyp dokumentiert. By: Codex (GPT-5) | +| 28.12.2025 | 📝 Req | ID: Update-Status-Schema dokumentiert. By: Codex (GPT-5) | +| 28.12.2025 | ⚙️ Code | ID: Update-Status-URL und Report im Client-Prototyp. By: Codex (GPT-5) | +| 28.12.2025 | 🏗️ Planning | ID: US_000029-US_000032 fuer Update-UI dokumentiert. By: Codex (GPT-5) | +| 28.12.2025 | 🏗️ Planning | ID: US_000033 Rollback-UI dokumentiert. By: Codex (GPT-5) | +| 28.12.2025 | 🏗️ Planning | ID: Update-UI Anforderungen um Backup-Voraussetzung ergaenzt. By: Codex (GPT-5) | +| 28.12.2025 | 🏗️ Planning | ID: TASK_000030-TASK_000034 fuer Update-UI ausgearbeitet. By: Codex (GPT-5) | +| 28.12.2025 | 📝 Req | ID: Update-API Endpunkte dokumentiert. By: Codex (GPT-5) | +| 28.12.2025 | 📝 Req | ID: Update-Status/Log Dateien dokumentiert. By: Codex (GPT-5) | +| 28.12.2025 | ⚙️ Code | ID: Update-Client Prototyp-Skript hinzugefuegt. By: Codex (GPT-5) | +| 29.12.2025 | ⚙️ Code | ID: Update-Backend Logik implementiert (backend/update.py). By: Codex (GPT-5) | +| 29.12.2025 | ⚙️ Code | ID: Update-Models und Settings erweitert (Models, Status-File Paths). By: Codex (GPT-5) | +| 29.12.2025 | ⚙️ Code | ID: Rollback-Script hinzugefuegt (scripts/rollback_client.sh). By: Codex (GPT-5) | +| 30.12.2025 | ⚙️ Code | ID: Update-API Endpunkte implementiert (GET /update/status, POST /update/check, POST /update/apply, POST /update/rollback, GET /update/logs). By: Claude Sonnet 4.5 | +| 30.12.2025 | ⚙️ Code | ID: Update-UI im Web-Frontend implementiert (Status-Anzeige, Check/Apply/Rollback Buttons, Logs-Viewer). By: Claude Sonnet 4.5 | --- ## Legende diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ff9e637 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,145 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Safe Kiddo Daemon (SKD) is a FastAPI-based service for managing local user accounts on kids' laptops. It provides account locking/unlocking with countdown notifications, optional sound alerts, and shutdown capabilities. The service exposes a REST API with bearer token auth (PAM or OIDC) and serves a minimal web UI. + +## Commands + +### Development +```bash +# Run the service manually (uses .venv, binds to 0.0.0.0:80) +./scripts/run.sh + +# Install service and dependencies +sudo make install + +# Service management +sudo make up # Start service +sudo make down # Stop service +sudo make update # Pull latest from git, reinstall deps, restart + +# Generate/set API token +make token + +# Health check (requires token) +make healthcheck +``` + +### Installation & Deployment +```bash +# Full install (creates service user, venv, systemd unit, PAM config) +sudo ./scripts/install.sh + +# Deploy to remote host (requires deploy_hosts.yml) +./scripts/deploy.sh + +# Manual update on target +ssh user@target 'cd /opt/sk && ./scripts/update.sh' +``` + +### Testing +```bash +# Python syntax validation +python -m py_compile backend/*.py + +# Run specific tests (no formal test runner yet; tests/ is empty) +# Use curl for API testing: +token=$(curl -s -X POST -H "Content-Type: application/json" \ + -d '{"username":"root","password":"..."}' \ + http://localhost/login | jq -r .token) +curl -H "Authorization: Bearer $token" http://localhost/users +``` + +## Architecture + +### Core Structure +- `backend/`: FastAPI application + - `app.py`: Main FastAPI app with route handlers + - `actions.py`: User management actions (lock/unlock, notifications, shutdown logic) + - `auth.py`: PAM authentication, JWT tokens, user/group authorization checks + - `oidc.py`: OIDC client (dynamic discovery, token exchange, claims validation) + - `update.py`: Update client logic (check/status/logs, triggers async update/rollback scripts) + - `settings.py`: Environment-based configuration (Settings class, singleton via lru_cache) + - `models.py`: Pydantic models for API requests/responses + - `templates/`: Jinja2 templates for web UI +- `scripts/`: Deployment and lifecycle scripts + - `install.sh`: System setup (user, venv, systemd, PAM config) + - `run.sh`: Manual service start + - `update.sh`: Local git pull and service restart + - `update_client.sh`: Full update flow with backup/rollback + - `rollback_client.sh`: Restore from backup if update fails + - `deploy.sh`: SSH-based deployment to remote hosts + - `register_oidc_client.sh`: OIDC dynamic client registration helper +- `sk.sh`: Legacy bash script (CLI fallback for direct SSH use) +- `src/`: Hexagonal architecture skeleton (core/ports/adapters/ui) - currently empty placeholders +- `docs/`: Detailed specs for OIDC validation, update API, status/log formats +- `Makefile`: Convenience targets for install, service control, updates, token management + +### Key Architectural Patterns + +**Dual Authentication**: PAM-based local auth (root/sudo users) is always available; OIDC is optional if `SKD_OIDC_ISSUER`, `SKD_OIDC_CLIENT_ID`, and `SKD_OIDC_CLIENT_SECRET` are configured. Both modes issue JWT bearer tokens. + +**Settings Management**: All config via environment variables (loaded from `/etc/skd/env` in production). `settings.py` provides a singleton `Settings` instance via `get_settings()` using `lru_cache`. FastAPI dependencies inject settings into route handlers. + +**Action Execution**: `actions.py` wraps all privileged operations (usermod, pkill, shutdown) via `_run()` helper. Dry-run mode (`SKD_DRY_RUN=true`) logs commands without executing them. + +**Update Flow**: `update.py` checks remote update service for new versions, writes status to JSON files, and triggers async scripts (`update_client.sh`, `rollback_client.sh`) that create backups, apply updates, and handle rollbacks on failure. + +**Authorization**: `auth.py` checks both user allowlists (`SKD_AUTH_ALLOWED_USERS`) and group membership (`SKD_AUTH_ALLOWED_GROUPS`, defaults to `sudo`). UID 0 (root) always allowed for PAM. OIDC validates against `preferred_username`, `email`, or `sub` claims. + +**Manageable Users**: Only system users with UID >= 1000, real shells (not nologin/false), and optional allowlist (`SKD_ALLOWED_USERS`) are exposed via API. Root accounts are never manageable. + +## Configuration + +Deployment config lives in `/etc/skd/env` (see `env.example` in repo root): +- `SKD_AUTH_SECRET`: HMAC secret for JWT signing (must be strong in production) +- `SKD_AUTH_ALLOWED_USERS`: Comma-separated user allowlist (for login and OIDC claims) +- `SKD_AUTH_ALLOWED_GROUPS`: Groups whose members may log in (PAM only, default `sudo`) +- `SKD_AUTH_PAM_SERVICE`: PAM service name (Ubuntu/Debian use `skd`, others may use `login`) +- `SKD_OIDC_*`: OIDC provider config (ISSUER, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, SCOPES) +- `SKD_ALLOWED_USERS`: Comma-separated list of manageable system accounts (optional) +- `SKD_DRY_RUN`: If `true`, logs all privileged commands without executing +- `SKD_UPDATE_*`: Update service URL, token, interval, status/log file paths + +## Important Workflows + +### Disable User Flow +1. API call to `/users/{username}/disable` with optional `{countdown, sound, message}` +2. `actions.disable_user()` locks account via `usermod -L` +3. If user logged in: sends desktop notifications, plays sound (if enabled), countdown loop with periodic reminders +4. Terminates sessions via `pkill -KILL -u` +5. Triggers `shutdown now` only if user was logged in + +### OIDC Login Flow +1. User accesses `/login/oidc/start` → redirected to provider with state cookie +2. Provider redirects to `/login/oidc/callback` with code + state +3. Validates state, exchanges code for tokens, extracts username from claims +4. Issues JWT session cookie if user in allowlist + +### Update Flow +1. `check_update()` polls remote update service for latest manifest (version, artifact_url, sha256) +2. `start_update()` writes "in_progress" status, launches `update_client.sh` in background +3. Script creates backup, downloads artifact, verifies checksum, installs, restarts service +4. On failure: `rollback_client.sh` restores from backup +5. Status/logs written to JSON files at `SKD_UPDATE_STATUS_FILE` and `SKD_UPDATE_LOG_FILE` + +## Security Considerations + +- Service runs as root by default (required for PAM, usermod, pkill, shutdown). Limit exposure via firewall. +- Set strong `SKD_AUTH_SECRET` and rotate by changing value + restarting service. +- Restrict API/Web UI to LAN/VPN; consider mTLS or IP allowlisting. +- `skd` user/group created by install script; consider sudoers rules to limit privileges to specific commands. +- OIDC redirect URI must match exactly (no wildcards); re-register client if host/port changes. +- Validate TLS certificates in production; self-signed certs require CA trust or fallback to PAM. + +## Notes + +- Legacy `sk.sh` remains for emergency CLI fallback; API is preferred for all operations. +- `src/` hexagonal architecture skeleton is currently unused; logic lives in `backend/`. +- `tests/` directory exists but is empty; use manual curl-based API testing. +- Both German and English comments exist in code; favor English going forward. +- Deployment via `deploy.sh` supports both YAML (`deploy_hosts.yml`) and JSON host configs. +- PAM service file (`/etc/pam.d/skd`) created by `scripts/install.sh` on Ubuntu/Debian; other distros may need manual setup. diff --git a/README.md b/README.md index 1563bac..1ea6ed8 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,13 @@ Set in `/etc/skd/env` (see `env.example`): - `SKD_DEFAULT_COUNTDOWN`, `SKD_DEFAULT_SOUND`, `SKD_NOTIFY_TIMEOUT`: behavior defaults. - `SKD_DRY_RUN=true` to test without real account changes or shutdown. - `SKD_SOUND_PLAYER`/`SKD_SOUND_FILE`, `SKD_NOTIFY_SEND_PATH` if defaults differ. +- Update client: + - `SKD_UPDATE_URL` (default `https://update.wlkns.org`) + - `SKD_UPDATE_TOKEN` (API token for update service) + - `SKD_UPDATE_INTERVAL` (seconds; default 3600) + - `SKD_UPDATE_STATUS_URL` (default `https://update.wlkns.org/status`) + - `SKD_UPDATE_STATUS_FILE` (default `/var/lib/skd/update_status.json`) + - `SKD_UPDATE_LOG_FILE` (default `/var/lib/skd/update_logs.jsonl`) Notes: - `./scripts/install.sh` will create `/etc/skd/env` from `env.example` if missing (edit afterwards) and ensure the `skd` service user/group exist. diff --git a/backend/app.py b/backend/app.py index b8e83e9..6dc1fa9 100644 --- a/backend/app.py +++ b/backend/app.py @@ -15,9 +15,20 @@ from backend.auth import ( issue_token, list_manageable_users, ) -from backend.models import ActionRequest, ActionResponse, LoginRequest, LoginResponse, UserStatus +from backend.models import ( + ActionRequest, + ActionResponse, + LoginRequest, + LoginResponse, + UpdateActionResponse, + UpdateCheckResponse, + UpdateLogEntry, + UpdateStatus, + UserStatus, +) from backend.oidc import OIDCClient, OIDCError from backend.settings import Settings, get_settings +from backend import update logging.basicConfig( level=logging.INFO, @@ -219,6 +230,67 @@ def enable_user( ) +@app.get("/update/status", response_model=UpdateStatus, dependencies=[Depends(get_current_admin)]) +def update_status(settings: Settings = Depends(get_settings)) -> UpdateStatus: + status_data = update.get_status(settings) + return UpdateStatus(**status_data) + + +@app.post("/update/check", response_model=UpdateCheckResponse, dependencies=[Depends(get_current_admin)]) +def update_check(settings: Settings = Depends(get_settings)) -> UpdateCheckResponse: + try: + check_data = update.check_update(settings) + except Exception as exc: + logger.exception("Update check failed") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Update check failed: {str(exc)}", + ) from exc + return UpdateCheckResponse(**check_data) + + +@app.post("/update/apply", response_model=UpdateActionResponse, dependencies=[Depends(get_current_admin)]) +def update_apply( + settings: Settings = Depends(get_settings), + payload: dict | None = Body(default=None), +) -> UpdateActionResponse: + version = payload.get("version") if payload else None + try: + update.start_update(settings, version) + except Exception as exc: + logger.exception("Failed to start update") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to start update: {str(exc)}", + ) from exc + return UpdateActionResponse(started=True, message="Update started") + + +@app.post("/update/rollback", response_model=UpdateActionResponse, dependencies=[Depends(get_current_admin)]) +def update_rollback(settings: Settings = Depends(get_settings)) -> UpdateActionResponse: + try: + update.start_rollback(settings) + except Exception as exc: + logger.exception("Failed to start rollback") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to start rollback: {str(exc)}", + ) from exc + return UpdateActionResponse(started=True, message="Rollback started") + + +@app.get("/update/logs", dependencies=[Depends(get_current_admin)]) +def update_logs(settings: Settings = Depends(get_settings), limit: int = 200) -> list[dict]: + try: + return update.get_logs(settings, limit) + except Exception as exc: + logger.exception("Failed to retrieve update logs") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve logs: {str(exc)}", + ) from exc + + @app.get("/", response_class=HTMLResponse) def index(request: Request) -> HTMLResponse: return templates.TemplateResponse("index.html", {"request": request}) diff --git a/backend/models.py b/backend/models.py index b290194..d6f83ca 100644 --- a/backend/models.py +++ b/backend/models.py @@ -34,3 +34,32 @@ class LoginRequest(BaseModel): class LoginResponse(BaseModel): token: str expires_in: int + + +class UpdateStatus(BaseModel): + current_version: str + last_status: str + last_error: Optional[str] = None + last_timestamp: Optional[str] = None + + +class UpdateCheckResponse(BaseModel): + available: bool + latest_version: str + artifact_url: str + sha256: str + message: Optional[str] = None + + +class UpdateActionResponse(BaseModel): + started: bool + message: str + + +class UpdateLogEntry(BaseModel): + timestamp: str + status: str + message: Optional[str] = None + version: Optional[str] = None + error: Optional[str] = None + device_id: Optional[str] = None diff --git a/backend/settings.py b/backend/settings.py index e4b1165..8d7663a 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -39,6 +39,18 @@ class Settings: self.default_sound: bool = os.getenv("SKD_DEFAULT_SOUND", "false").lower() == "true" 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_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" + ) + self.update_status_file: str = os.getenv( + "SKD_UPDATE_STATUS_FILE", "/var/lib/skd/update_status.json" + ) + self.update_log_file: str = os.getenv( + "SKD_UPDATE_LOG_FILE", "/var/lib/skd/update_logs.jsonl" + ) # Paths/tools self.notify_send_path: str = os.getenv("SKD_NOTIFY_SEND_PATH", "notify-send") self.sound_player: str = os.getenv("SKD_SOUND_PLAYER", "paplay") diff --git a/backend/templates/index.html b/backend/templates/index.html index d355c44..1bd3bdd 100644 --- a/backend/templates/index.html +++ b/backend/templates/index.html @@ -84,6 +84,27 @@
+
+

Update-Verwaltung

+
+ Lade Update-Status... +
+ +
+ + + +
+ +
+ +
+ Update-Logs anzeigen + +
+
+
+ diff --git a/backend/update.py b/backend/update.py new file mode 100644 index 0000000..8629dc5 --- /dev/null +++ b/backend/update.py @@ -0,0 +1,142 @@ +import json +import os +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List + +import httpx + +from backend.settings import Settings + + +def _project_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def _read_version() -> str: + try: + return (_project_root() / "VERSION").read_text(encoding="utf-8").strip() + except OSError: + return "unknown" + + +def _status_path(settings: Settings) -> Path: + return Path(settings.update_status_file) + + +def _log_path(settings: Settings) -> Path: + return Path(settings.update_log_file) + + +def _ensure_parent(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def _write_status(settings: Settings, status: str, version: str, error: str | None = None) -> None: + status_path = _status_path(settings) + _ensure_parent(status_path) + payload = { + "device_id": os.uname().nodename, + "version": version, + "status": status, + "error": error or "", + "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + status_path.write_text(json.dumps(payload), encoding="utf-8") + _append_log(settings, payload) + + +def _append_log(settings: Settings, payload: Dict[str, Any]) -> None: + log_path = _log_path(settings) + _ensure_parent(log_path) + with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") + + +def get_status(settings: Settings) -> Dict[str, Any]: + current_version = _read_version() + status_path = _status_path(settings) + if status_path.exists(): + try: + data = json.loads(status_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + data = {} + else: + data = {} + + return { + "current_version": current_version, + "last_status": data.get("status", "unknown"), + "last_error": data.get("error") or None, + "last_timestamp": data.get("timestamp"), + } + + +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}" + + with httpx.Client(timeout=10.0) as client: + response = client.get(settings.update_url, headers=headers) + response.raise_for_status() + manifest = response.json() + + latest_version = manifest.get("version", "") + artifact_url = manifest.get("artifact_url", "") + sha256 = manifest.get("sha256", "") + message = manifest.get("message") + + available = False + current_version = _read_version() + try: + available = _parse_version(latest_version) > _parse_version(current_version) + except ValueError: + if latest_version and latest_version != current_version: + available = True + + return { + "available": available, + "latest_version": latest_version, + "artifact_url": artifact_url, + "sha256": sha256, + "message": message, + } + + +def _run_async(script_path: Path, settings: Settings) -> None: + env = os.environ.copy() + 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) + + +def start_update(settings: Settings, version: str | None = None) -> None: + _write_status(settings, "in_progress", version or _read_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()) + script = _project_root() / "scripts" / "rollback_client.sh" + _run_async(script, settings) + + +def get_logs(settings: Settings, limit: int = 200) -> List[Dict[str, Any]]: + log_path = _log_path(settings) + if not log_path.exists(): + return [] + lines = log_path.read_text(encoding="utf-8").splitlines() + entries: List[Dict[str, Any]] = [] + for line in lines[-limit:]: + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + return entries diff --git a/docs/update-api.md b/docs/update-api.md new file mode 100644 index 0000000..5a7b6fd --- /dev/null +++ b/docs/update-api.md @@ -0,0 +1,60 @@ +ID: DOC_000006 | Version: 0.1.0 | Status: Draft +By: Codex (GPT-5) + +# Update API (Kiddo Backend) + +## Purpose +Definiert interne API-Endpunkte fuer Update-Status, Check, Apply, Rollback und Logs. + +## Endpoints + +### GET /update/status +Antwort: +```json +{ + "current_version": "0.1.2", + "last_status": "success|failed|unknown", + "last_error": "", + "last_timestamp": "2025-12-28T12:34:56Z" +} +``` + +### POST /update/check +Antwort: +```json +{ + "available": true, + "latest_version": "0.1.3", + "artifact_url": "https://update.wlkns.org/kiddo/kiddo-0.1.3.tar.gz", + "sha256": "", + "message": "" +} +``` + +### POST /update/apply +Body (optional): +```json +{ "version": "0.1.3" } +``` +Antwort: +```json +{ "started": true, "message": "update started" } +``` + +### POST /update/rollback +Antwort: +```json +{ "started": true, "message": "rollback started" } +``` + +### GET /update/logs +Antwort: +```json +[ + {"timestamp":"2025-12-28T12:34:56Z","status":"success","message":"updated to 0.1.2"} +] +``` + +## Notes +- Alle Endpunkte erfordern Auth (Session/Bearer). +- Apply/Rollback starten async; UI pollt /update/status. diff --git a/docs/update-client.md b/docs/update-client.md new file mode 100644 index 0000000..adc1ada --- /dev/null +++ b/docs/update-client.md @@ -0,0 +1,49 @@ +ID: DOC_000004 | Version: 0.1.0 | Status: Draft +By: Codex (GPT-5) + +# Client Update Flow (Kiddo) + +## Purpose +Definiert den Client-seitigen Ablauf fuer das Pull-Update vom Update-Service. + +## Manifest Format (JSON) +Beispiel: +```json +{ + "version": "0.1.2", + "artifact_url": "https://update.wlkns.org/kiddo/kiddo-0.1.2.tar.gz", + "sha256": "", + "sig_url": "https://update.wlkns.org/kiddo/kiddo-0.1.2.sig" +} +``` + +## Flow (High Level) +1. Manifest abrufen (auth optional via Bearer Token). +2. `artifact_url` herunterladen. +3. SHA256 pruefen (Signatur optional). +4. In Staging-Verzeichnis entpacken. +5. Service stoppen. +6. Atomic swap: aktuelles Verzeichnis sichern, Staging nach `/opt/sk` verschieben. +7. Service starten. +8. Bei Fehlern Rollback auf Backup. + +## Prototype Script +- `scripts/update_client.sh` implementiert den Flow als CLI-Prototyp. +- Erfordert `curl`, `tar`, `sha256sum`, `python3` und `systemctl`. + +## Rollback +- Wenn Start fehlschlaegt: Backup nach `/opt/sk` zurueck, Service neu starten. +- Backup-Verzeichnis benoetigt genuegend Speicher. + +## Security Notes +- Artefakte muessen checksum-verifiziert sein. +- Token-Handling ueber `SKD_UPDATE_TOKEN`. + +## Constraints +- Update-Service ist extern (update.wlkns.org). +- Service muss als root stoppen/starten koennen. + +## Status Reporting +- Status wird per HTTP POST an `https://update.wlkns.org/status` gemeldet. +- Schema siehe `docs/update-status.md`. +- Lokaler Status/Logs liegen unter `/var/lib/skd` (konfigurierbar via ENV). diff --git a/docs/update-status.md b/docs/update-status.md new file mode 100644 index 0000000..a83af27 --- /dev/null +++ b/docs/update-status.md @@ -0,0 +1,25 @@ +ID: DOC_000005 | Version: 0.1.0 | Status: Draft +By: Codex (GPT-5) + +# Update Status Reporting + +## Purpose +Definiert das Status-Schema fuer Update-Resultate und den Uebertragungsweg. + +## Status Schema (JSON) +```json +{ + "device_id": "", + "version": "0.1.2", + "status": "success|failed", + "error": "", + "timestamp": "2025-12-28T12:34:56Z" +} +``` + +## Transport +- HTTP POST an `https://update.wlkns.org/status` +- Auth: Bearer Token (`SKD_UPDATE_TOKEN`) + +## Notes +- Statusmeldungen sind best-effort; Fehler beim Senden blockieren kein Update. diff --git a/env.example b/env.example index 41120b9..5aae8f8 100644 --- a/env.example +++ b/env.example @@ -21,5 +21,12 @@ SKD_OIDC_STATE_COOKIE_NAME=skd_oidc_state SKD_DEFAULT_COUNTDOWN=60 SKD_DEFAULT_SOUND=false SKD_NOTIFY_TIMEOUT=5 +# Update client configuration +SKD_UPDATE_URL=https://update.wlkns.org +SKD_UPDATE_TOKEN= +SKD_UPDATE_INTERVAL=3600 +SKD_UPDATE_STATUS_URL=https://update.wlkns.org/status +SKD_UPDATE_STATUS_FILE=/var/lib/skd/update_status.json +SKD_UPDATE_LOG_FILE=/var/lib/skd/update_logs.jsonl # Set to true to test without performing real system changes SKD_DRY_RUN=false diff --git a/project-management/PROJECT_STATUS.md b/project-management/PROJECT_STATUS.md index 6371fc8..0d88290 100644 --- a/project-management/PROJECT_STATUS.md +++ b/project-management/PROJECT_STATUS.md @@ -10,9 +10,9 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten. ✅ Stabilization ## Aktueller Fokus -1. Dokumentierter Ist-Zustand der Module. -2. Pflege der Anforderungen bei neuen Features. -3. Doku und Ops-Automation aktuell halten. +1. Client-Update-Mechanik planen (EPIC_000008). +2. Dokumentierter Ist-Zustand der Module. +3. Pflege der Anforderungen bei neuen Features. ## Projekt-Tagebuch (Kurz, optional) | Datum | Typ | Beschreibung | @@ -86,12 +86,22 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten. - [x] TASK_000023: README runbook notes ### EPIC_000008: Client-Side Update Mechanism -- [ ] US_000026: Client bezieht Updates (Pull) -- [ ] TASK_000027: Update endpoint config -- [ ] US_000027: Client verifiziert und wendet Updates an -- [ ] TASK_000028: Verify and apply update -- [ ] US_000028: Client meldet Update-Status -- [ ] TASK_000029: Report update status +- [x] US_000026: Client bezieht Updates (Pull) +- [x] TASK_000027: Update endpoint config +- [x] US_000027: Client verifiziert und wendet Updates an +- [x] TASK_000028: Verify and apply update +- [x] US_000028: Client meldet Update-Status +- [x] TASK_000029: Report update status +- [x] US_000029: Update-Status im Web-UI anzeigen +- [x] TASK_000030: UI update status view +- [x] US_000030: Update-Check im Web-UI ausloesen +- [x] TASK_000031: UI update check trigger +- [x] US_000031: Update im Web-UI anstossen +- [x] TASK_000032: UI update apply action +- [x] US_000032: Update-Logs im Web-UI anzeigen +- [x] TASK_000033: UI update logs view +- [x] US_000033: Rollback im Web-UI anstossen +- [x] TASK_000034: UI rollback action ### EPIC_000009: Update Webservice (External Team) - [ ] US_000026: Client bezieht Updates (Pull) diff --git a/project-management/requirements/epics/EPIC_000008.md b/project-management/requirements/epics/EPIC_000008.md index 39aeb13..e0bd41c 100644 --- a/project-management/requirements/epics/EPIC_000008.md +++ b/project-management/requirements/epics/EPIC_000008.md @@ -39,3 +39,8 @@ Ermoegliche einen robusten Client-Update-Flow mit Verifikation und Rollback. - US_000026: Client bezieht Updates (Pull) - US_000027: Client verifiziert und wendet Updates an - US_000028: Client meldet Update-Status +- US_000029: Update-Status im Web-UI anzeigen +- US_000030: Update-Check im Web-UI ausloesen +- US_000031: Update im Web-UI anstossen +- US_000032: Update-Logs im Web-UI anzeigen +- US_000033: Rollback im Web-UI anstossen diff --git a/project-management/requirements/stories/US_000026.md b/project-management/requirements/stories/US_000026.md index 4472a65..08c02cc 100644 --- a/project-management/requirements/stories/US_000026.md +++ b/project-management/requirements/stories/US_000026.md @@ -1,9 +1,9 @@ -ID: US_000026 | Version: 0.1.0 | Status: Draft +ID: US_000026 | Version: 0.1.0 | Status: Done By: Codex (GPT-5) # US_000026: Client bezieht Updates (Pull) -Status: Draft +Status: Done Als Betreiber moechte ich, dass der Client Updates per Pull von einem Update-Service bezieht, damit Deployments ohne SSH moeglich sind. diff --git a/project-management/requirements/stories/US_000027.md b/project-management/requirements/stories/US_000027.md index afc6b89..7a8a24d 100644 --- a/project-management/requirements/stories/US_000027.md +++ b/project-management/requirements/stories/US_000027.md @@ -1,9 +1,9 @@ -ID: US_000027 | Version: 0.1.0 | Status: Draft +ID: US_000027 | Version: 0.1.0 | Status: Done By: Codex (GPT-5) # US_000027: Client verifiziert und wendet Updates an -Status: Draft +Status: Done Als Betreiber moechte ich, dass der Client Updates verifiziert und sicher anwendet, damit fehlerhafte Pakete keine Ausfaelle verursachen. diff --git a/project-management/requirements/stories/US_000028.md b/project-management/requirements/stories/US_000028.md index 27848c0..9a3e182 100644 --- a/project-management/requirements/stories/US_000028.md +++ b/project-management/requirements/stories/US_000028.md @@ -1,9 +1,9 @@ -ID: US_000028 | Version: 0.1.0 | Status: Draft +ID: US_000028 | Version: 0.1.0 | Status: Done By: Codex (GPT-5) # US_000028: Client meldet Update-Status -Status: Draft +Status: Done Als Betreiber moechte ich Statusmeldungen vom Client erhalten, damit Update-Ergebnisse nachvollziehbar sind. diff --git a/project-management/requirements/stories/US_000029.md b/project-management/requirements/stories/US_000029.md new file mode 100644 index 0000000..82b2e78 --- /dev/null +++ b/project-management/requirements/stories/US_000029.md @@ -0,0 +1,17 @@ +ID: US_000029 | Version: 0.1.0 | Status: Done +By: Codex (GPT-5) + +# US_000029: Update-Status im Web-UI anzeigen + +Status: Done + +Als Admin moechte ich die aktuelle Version und den Update-Status im Web-UI sehen, damit ich den Zustand schnell pruefen kann. + +## Akzeptanzkriterien +- Given die Web-UI ist erreichbar +- When ich den Update-Bereich aufrufe +- Then ich sehe die aktuell laufende Version +- And ich sehe den letzten Update-Status (success/failed) mit Zeitstempel + +## Task-Platzhalter +- TASK_000030: UI update status view (Details bei Story-Start) diff --git a/project-management/requirements/stories/US_000030.md b/project-management/requirements/stories/US_000030.md new file mode 100644 index 0000000..afce1e0 --- /dev/null +++ b/project-management/requirements/stories/US_000030.md @@ -0,0 +1,17 @@ +ID: US_000030 | Version: 0.1.0 | Status: Done +By: Codex (GPT-5) + +# US_000030: Update-Check im Web-UI ausloesen + +Status: Done + +Als Admin moechte ich manuell nach Updates suchen koennen, damit ich Updates sofort pruefen kann. + +## Akzeptanzkriterien +- Given die Web-UI ist erreichbar +- When ich auf "Nach Updates suchen" klicke +- Then wird ein Check gegen `https://update.wlkns.org` gestartet +- And das Ergebnis (neue Version verfuegbar/keine Updates/Fehler) wird angezeigt + +## Task-Platzhalter +- TASK_000031: UI update check trigger (Details bei Story-Start) diff --git a/project-management/requirements/stories/US_000031.md b/project-management/requirements/stories/US_000031.md new file mode 100644 index 0000000..19e3fee --- /dev/null +++ b/project-management/requirements/stories/US_000031.md @@ -0,0 +1,19 @@ +ID: US_000031 | Version: 0.1.0 | Status: Done +By: Codex (GPT-5) + +# US_000031: Update im Web-UI anstossen + +Status: Done + +Als Admin moechte ich ein Update im Web-UI anstossen, damit der Client die neue Version installiert. + +## Akzeptanzkriterien +- Given ein Update ist verfuegbar +- And eine vorherige Version ist gesichert oder wird vor dem Start gesichert +- When ich "Update installieren" ausloese +- Then wird der Update-Client gestartet +- And der Fortschritt/Status wird im UI angezeigt +- And Fehler werden klar im UI gemeldet + +## Task-Platzhalter +- TASK_000032: UI update apply action (Details bei Story-Start) diff --git a/project-management/requirements/stories/US_000032.md b/project-management/requirements/stories/US_000032.md new file mode 100644 index 0000000..81cc7b2 --- /dev/null +++ b/project-management/requirements/stories/US_000032.md @@ -0,0 +1,17 @@ +ID: US_000032 | Version: 0.1.0 | Status: Done +By: Codex (GPT-5) + +# US_000032: Update-Logs im Web-UI anzeigen + +Status: Done + +Als Admin moechte ich Update-Logs im Web-UI einsehen, damit Fehler nachvollziehbar sind. + +## Akzeptanzkriterien +- Given ein Update-Versuch wurde ausgefuehrt +- When ich die Update-Logs oeffne +- Then sehe ich eine chronologische Liste mit Zeitstempel und Ergebnis +- And sensible Daten (Tokens) werden nicht angezeigt + +## Task-Platzhalter +- TASK_000033: UI update logs view (Details bei Story-Start) diff --git a/project-management/requirements/stories/US_000033.md b/project-management/requirements/stories/US_000033.md new file mode 100644 index 0000000..67540c1 --- /dev/null +++ b/project-management/requirements/stories/US_000033.md @@ -0,0 +1,19 @@ +ID: US_000033 | Version: 0.1.0 | Status: Done +By: Codex (GPT-5) + +# US_000033: Rollback im Web-UI anstossen + +Status: Done + +Als Admin moechte ich einen Rollback im Web-UI anstossen, damit ich nach einem fehlerhaften Update schnell zur letzten Version zurueckkehre. + +## Akzeptanzkriterien +- Given ein vorheriger Update-Stand ist verfuegbar und wurde gesichert +- And der Rollback darf nur starten, wenn das Backup verfuegbar ist +- When ich "Rollback" ausloese +- Then wird der Rollback-Mechanismus gestartet +- And der Status/Fortschritt wird im UI angezeigt +- And Fehler werden klar im UI gemeldet + +## Task-Platzhalter +- TASK_000034: UI rollback action (Details bei Story-Start) diff --git a/project-management/requirements/tasks/TASK_000027.md b/project-management/requirements/tasks/TASK_000027.md new file mode 100644 index 0000000..b400851 --- /dev/null +++ b/project-management/requirements/tasks/TASK_000027.md @@ -0,0 +1,20 @@ +ID: TASK_000027 | Version: 0.1.0 | Status: Done +By: Codex (GPT-5) + +# TASK_000027: Update endpoint config + +## Outcome +Client kennt Update-Endpoint, Auth und Polling-Konfiguration. + +## Story-Bezug +US_000026 + +## Beschreibung +- Konfigurationskeys definieren (`SKD_UPDATE_URL`, `SKD_UPDATE_TOKEN`, `SKD_UPDATE_INTERVAL`). +- Default auf `https://update.wlkns.org` festlegen. +- Lesen der Config in Settings/ENV beschreiben. + +## Definition of Done (DoD) +- Konfigurations-Keys dokumentiert (`env.example`, README). +- Default-URL ist festgelegt (`https://update.wlkns.org`). +- Settings laden die Update-Konfiguration aus ENV. diff --git a/project-management/requirements/tasks/TASK_000028.md b/project-management/requirements/tasks/TASK_000028.md new file mode 100644 index 0000000..0cc4207 --- /dev/null +++ b/project-management/requirements/tasks/TASK_000028.md @@ -0,0 +1,23 @@ +ID: TASK_000028 | Version: 0.1.0 | Status: Done +By: Codex (GPT-5) + +# TASK_000028: Verify and apply update + +## Outcome +Update-Artefakt wird verifiziert und atomar angewendet. + +## Story-Bezug +US_000027 + +## Beschreibung +- JSON-Manifest lesen (Version, `artifact_url`, `sha256`, optional `sig_url`). +- `tar.gz` herunterladen und SHA256 pruefen. +- Update atomar anwenden (staging, swap, rollback). +- Fehlerfall dokumentieren (Rollback, Status). +- Ablauf dokumentiert in `docs/update-client.md`. + +## Definition of Done (DoD) +- Verifikation (Checksum/Signatur) ist beschrieben. +- Atomare Anwendung und Rollback-Strategie sind definiert. +- Fehlerbilder sind erfasst. +- Prototyp-Skript ist als Referenz vorhanden. diff --git a/project-management/requirements/tasks/TASK_000029.md b/project-management/requirements/tasks/TASK_000029.md new file mode 100644 index 0000000..0b1d02c --- /dev/null +++ b/project-management/requirements/tasks/TASK_000029.md @@ -0,0 +1,21 @@ +ID: TASK_000029 | Version: 0.1.0 | Status: Done +By: Codex (GPT-5) + +# TASK_000029: Report update status + +## Outcome +Client meldet Update-Status mit Version und Fehlerbild. + +## Story-Bezug +US_000028 + +## Beschreibung +- Status-Schema definieren (Version, Ergebnis, Fehler, Zeitpunkt). +- Transportweg festlegen (z.B. HTTP POST oder Log-Export). +- Erfolg/Fehler konsistent dokumentieren. +- Schema dokumentiert in `docs/update-status.md`. + +## Definition of Done (DoD) +- Status-Schema dokumentiert. +- Uebertragungspfad beschrieben. +- Erfolg/Fehler werden eindeutig gemeldet. diff --git a/project-management/requirements/tasks/TASK_000030.md b/project-management/requirements/tasks/TASK_000030.md new file mode 100644 index 0000000..8d75e27 --- /dev/null +++ b/project-management/requirements/tasks/TASK_000030.md @@ -0,0 +1,19 @@ +ID: TASK_000030 | Version: 0.1.0 | Status: Done +By: Codex (GPT-5) + +# TASK_000030: UI update status view + +## Outcome +Web-UI zeigt aktuelle Version und letzten Update-Status. + +## Story-Bezug +US_000029 + +## Beschreibung +- Update-Status und Version im UI anzeigen. +- Letzten Status mit Zeitstempel visualisieren. +- API-Basis: `GET /update/status` (siehe `docs/update-api.md`). + +## Definition of Done (DoD) +- UI zeigt Version + letzten Status. +- Fehler/keine Daten werden sauber angezeigt. diff --git a/project-management/requirements/tasks/TASK_000031.md b/project-management/requirements/tasks/TASK_000031.md new file mode 100644 index 0000000..c60340e --- /dev/null +++ b/project-management/requirements/tasks/TASK_000031.md @@ -0,0 +1,19 @@ +ID: TASK_000031 | Version: 0.1.0 | Status: Done +By: Codex (GPT-5) + +# TASK_000031: UI update check trigger + +## Outcome +Web-UI kann einen Update-Check gegen `https://update.wlkns.org` ausloesen. + +## Story-Bezug +US_000030 + +## Beschreibung +- Button/Action fuer "Nach Updates suchen". +- Ergebnisanzeige (Update verfuegbar/keine Updates/Fehler). +- API-Basis: `POST /update/check` (siehe `docs/update-api.md`). + +## Definition of Done (DoD) +- UI zeigt Ergebnis des Update-Checks. +- Fehler werden klar angezeigt. diff --git a/project-management/requirements/tasks/TASK_000032.md b/project-management/requirements/tasks/TASK_000032.md new file mode 100644 index 0000000..3f85017 --- /dev/null +++ b/project-management/requirements/tasks/TASK_000032.md @@ -0,0 +1,21 @@ +ID: TASK_000032 | Version: 0.1.0 | Status: Done +By: Codex (GPT-5) + +# TASK_000032: UI update apply action + +## Outcome +Web-UI kann ein Update installieren. + +## Story-Bezug +US_000031 + +## Beschreibung +- "Update installieren" Action. +- Vor Start Backup-Pruefung/Backup-Erstellung erzwingen. +- Fortschritt/Status im UI. +- API-Basis: `POST /update/apply` (siehe `docs/update-api.md`). + +## Definition of Done (DoD) +- Update startet nur mit vorhandenem Backup. +- UI zeigt Fortschritt/Status. +- Fehler sind sichtbar. diff --git a/project-management/requirements/tasks/TASK_000033.md b/project-management/requirements/tasks/TASK_000033.md new file mode 100644 index 0000000..c476874 --- /dev/null +++ b/project-management/requirements/tasks/TASK_000033.md @@ -0,0 +1,19 @@ +ID: TASK_000033 | Version: 0.1.0 | Status: Done +By: Codex (GPT-5) + +# TASK_000033: UI update logs view + +## Outcome +Web-UI zeigt Update-Logs chronologisch. + +## Story-Bezug +US_000032 + +## Beschreibung +- Logliste mit Zeitstempel und Ergebnis. +- Keine sensiblen Daten anzeigen. +- API-Basis: `GET /update/logs` (siehe `docs/update-api.md`). + +## Definition of Done (DoD) +- Logs sind sichtbar und chronologisch sortiert. +- Sensible Daten sind ausgefiltert. diff --git a/project-management/requirements/tasks/TASK_000034.md b/project-management/requirements/tasks/TASK_000034.md new file mode 100644 index 0000000..1848b52 --- /dev/null +++ b/project-management/requirements/tasks/TASK_000034.md @@ -0,0 +1,20 @@ +ID: TASK_000034 | Version: 0.1.0 | Status: Done +By: Codex (GPT-5) + +# TASK_000034: UI rollback action + +## Outcome +Web-UI kann einen Rollback ausloesen. + +## Story-Bezug +US_000033 + +## Beschreibung +- "Rollback" Action. +- Nur aktiv, wenn Backup vorhanden. +- Fortschritt/Status im UI. +- API-Basis: `POST /update/rollback` (siehe `docs/update-api.md`). + +## Definition of Done (DoD) +- Rollback startet nur bei verfuegbarem Backup. +- UI zeigt Status/Fehler. diff --git a/scripts/rollback_client.sh b/scripts/rollback_client.sh new file mode 100755 index 0000000..85026b2 --- /dev/null +++ b/scripts/rollback_client.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +SERVICE_NAME="${SERVICE_NAME:-skd}" +INSTALL_DIR="${INSTALL_DIR:-/opt/sk}" +STATUS_FILE="${SKD_UPDATE_STATUS_FILE:-/var/lib/skd/update_status.json}" +LOG_FILE="${SKD_UPDATE_LOG_FILE:-/var/lib/skd/update_logs.jsonl}" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +write_status() { + local status="$1" + local error="${2:-}" + local version="$3" + SKD_STATUS="${status}" SKD_ERROR="${error}" SKD_VERSION="${version}" \ + SKD_STATUS_FILE="${STATUS_FILE}" SKD_LOG_FILE="${LOG_FILE}" python3 - <<'PY' +import json +import os +from datetime import datetime, timezone +from pathlib import Path + +status = os.environ["SKD_STATUS"] +error = os.environ.get("SKD_ERROR", "") +version = os.environ.get("SKD_VERSION", "unknown") +status_file = Path(os.environ["SKD_STATUS_FILE"]) +log_file = Path(os.environ["SKD_LOG_FILE"]) + +status_file.parent.mkdir(parents=True, exist_ok=True) +log_file.parent.mkdir(parents=True, exist_ok=True) + +payload = { + "device_id": os.uname().nodename, + "version": version, + "status": status, + "error": error, + "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), +} +status_file.write_text(json.dumps(payload), encoding="utf-8") +with log_file.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +PY +} + +LATEST_BACKUP="$(ls -dt /opt/sk_backup_* 2>/dev/null | head -1 || true)" +if [[ -z "${LATEST_BACKUP}" ]]; then + log "No backup found; rollback aborted." + write_status "failed" "no backup found" "unknown" + exit 1 +fi + +VERSION="unknown" +if [[ -f "${LATEST_BACKUP}/VERSION" ]]; then + VERSION="$(cat "${LATEST_BACKUP}/VERSION" | tr -d '\n')" +fi + +log "Stopping service ${SERVICE_NAME}..." +sudo systemctl stop "${SERVICE_NAME}.service" + +FAILED_DIR="${INSTALL_DIR}_failed_$(date +%s)" +log "Swapping ${INSTALL_DIR} -> ${FAILED_DIR}..." +if [[ -d "${INSTALL_DIR}" ]]; then + sudo mv "${INSTALL_DIR}" "${FAILED_DIR}" +fi +sudo mv "${LATEST_BACKUP}" "${INSTALL_DIR}" + +log "Starting service ${SERVICE_NAME}..." +if sudo systemctl start "${SERVICE_NAME}.service"; then + log "Rollback completed." + write_status "success" "" "${VERSION}" +else + log "Rollback failed." + write_status "failed" "service start failed" "${VERSION}" + exit 1 +fi diff --git a/scripts/update_client.sh b/scripts/update_client.sh new file mode 100755 index 0000000..d3b4782 --- /dev/null +++ b/scripts/update_client.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +set -euo pipefail + +SERVICE_NAME="${SERVICE_NAME:-skd}" +UPDATE_URL="${SKD_UPDATE_URL:-https://update.wlkns.org}" +UPDATE_TOKEN="${SKD_UPDATE_TOKEN:-}" +STATUS_URL="${SKD_UPDATE_STATUS_URL:-https://update.wlkns.org/status}" +STATUS_FILE="${SKD_UPDATE_STATUS_FILE:-/var/lib/skd/update_status.json}" +LOG_FILE="${SKD_UPDATE_LOG_FILE:-/var/lib/skd/update_logs.jsonl}" +INSTALL_DIR="${INSTALL_DIR:-/opt/sk}" +STAGING_DIR="" +MANIFEST_FILE="" +ARTIFACT_FILE="" +BACKUP_DIR="" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +report_status() { + local status="$1" + local error="${2:-}" + local timestamp + timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + python3 - <<'PY' +import json +import os +from datetime import datetime, timezone +from pathlib import Path + +device_id = os.uname().nodename +version = os.environ.get("SKD_VERSION", "unknown") +status = os.environ.get("SKD_STATUS", "unknown") +error = os.environ.get("SKD_ERROR", "") +timestamp = os.environ.get("SKD_TIMESTAMP") or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") +status_file = Path(os.environ["SKD_STATUS_FILE"]) +log_file = Path(os.environ["SKD_LOG_FILE"]) + +status_file.parent.mkdir(parents=True, exist_ok=True) +log_file.parent.mkdir(parents=True, exist_ok=True) + +payload = { + "device_id": device_id, + "version": version, + "status": status, + "error": error, + "timestamp": timestamp, +} + +status_file.write_text(json.dumps(payload), encoding="utf-8") +with log_file.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +PY + local payload + payload=$(cat </dev/null || true + else + curl -sS -X POST -H "Content-Type: application/json" -d "${payload}" "${STATUS_URL}" >/dev/null || true + fi +} + +cleanup() { + if [[ -n "${STAGING_DIR}" && -d "${STAGING_DIR}" ]]; then + rm -rf "${STAGING_DIR}" + fi + if [[ -n "${MANIFEST_FILE}" && -f "${MANIFEST_FILE}" ]]; then + rm -f "${MANIFEST_FILE}" + fi + if [[ -n "${ARTIFACT_FILE}" && -f "${ARTIFACT_FILE}" ]]; then + rm -f "${ARTIFACT_FILE}" + fi +} +trap cleanup EXIT + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Required command not found: $1" >&2 + exit 1 + fi +} + +require_cmd curl +require_cmd tar +require_cmd sha256sum +require_cmd python3 + +log "Fetching update manifest from ${UPDATE_URL}..." +MANIFEST_FILE="$(mktemp)" +if [[ -n "${UPDATE_TOKEN}" ]]; then + curl -fsS -H "Authorization: Bearer ${UPDATE_TOKEN}" "${UPDATE_URL}" -o "${MANIFEST_FILE}" +else + curl -fsS "${UPDATE_URL}" -o "${MANIFEST_FILE}" +fi + +read_manifest() { + python3 - <<'PY' "${MANIFEST_FILE}" +import json +import sys +path = sys.argv[1] +with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) +for key in ("version", "artifact_url", "sha256"): + if key not in data: + sys.exit(f"Manifest missing '{key}'") +print(data["version"]) +print(data["artifact_url"]) +print(data["sha256"]) +PY +} + +readarray -t manifest_values < <(read_manifest) +VERSION="${manifest_values[0]}" +ARTIFACT_URL="${manifest_values[1]}" +ARTIFACT_SHA256="${manifest_values[2]}" + +log "Downloading artifact ${ARTIFACT_URL} (version ${VERSION})..." +ARTIFACT_FILE="$(mktemp --suffix=.tar.gz)" +if [[ -n "${UPDATE_TOKEN}" ]]; then + curl -fsS -H "Authorization: Bearer ${UPDATE_TOKEN}" "${ARTIFACT_URL}" -o "${ARTIFACT_FILE}" +else + curl -fsS "${ARTIFACT_URL}" -o "${ARTIFACT_FILE}" +fi + +log "Verifying checksum..." +CALC_SHA="$(sha256sum "${ARTIFACT_FILE}" | awk '{print $1}')" +if [[ "${CALC_SHA}" != "${ARTIFACT_SHA256}" ]]; then + echo "Checksum mismatch: expected ${ARTIFACT_SHA256} got ${CALC_SHA}" >&2 + SKD_VERSION="${VERSION}" SKD_STATUS="failed" SKD_ERROR="checksum mismatch" \ + SKD_TIMESTAMP="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" SKD_STATUS_FILE="${STATUS_FILE}" \ + SKD_LOG_FILE="${LOG_FILE}" report_status "failed" "checksum mismatch" + exit 1 +fi + +STAGING_DIR="$(mktemp -d /opt/sk_update.XXXXXX)" +log "Extracting to staging ${STAGING_DIR}..." + tar -xzf "${ARTIFACT_FILE}" -C "${STAGING_DIR}" + +BACKUP_DIR="/opt/sk_backup_${VERSION}_$(date +%s)" +log "Stopping service ${SERVICE_NAME}..." + sudo systemctl stop "${SERVICE_NAME}.service" + +log "Swapping ${INSTALL_DIR} -> ${BACKUP_DIR}..." +if [[ -d "${INSTALL_DIR}" ]]; then + sudo mv "${INSTALL_DIR}" "${BACKUP_DIR}" +fi +sudo mv "${STAGING_DIR}" "${INSTALL_DIR}" +STAGING_DIR="" + +log "Starting service ${SERVICE_NAME}..." +if sudo systemctl start "${SERVICE_NAME}.service"; then + log "Update applied successfully." + SKD_VERSION="${VERSION}" SKD_STATUS="success" SKD_ERROR="" \ + SKD_TIMESTAMP="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" SKD_STATUS_FILE="${STATUS_FILE}" \ + SKD_LOG_FILE="${LOG_FILE}" report_status "success" "" +else + log "Service failed to start, rolling back..." + sudo rm -rf "${INSTALL_DIR}" + if [[ -d "${BACKUP_DIR}" ]]; then + sudo mv "${BACKUP_DIR}" "${INSTALL_DIR}" + fi + sudo systemctl start "${SERVICE_NAME}.service" || true + SKD_VERSION="${VERSION}" SKD_STATUS="failed" SKD_ERROR="service start failed" \ + SKD_TIMESTAMP="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" SKD_STATUS_FILE="${STATUS_FILE}" \ + SKD_LOG_FILE="${LOG_FILE}" report_status "failed" "service start failed" + exit 1 +fi