From 107cdabe8d00613b8084a2fa97a80d305d67b440 Mon Sep 17 00:00:00 2001 From: stephan Date: Tue, 30 Dec 2025 11:18:34 +0100 Subject: [PATCH] feat: implement update backend API and client logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add complete update mechanism for client-side updates: - backend/update.py: Core update logic (check, apply, rollback, status/logs) - backend/app.py: REST API endpoints (GET /update/status, POST /update/check, POST /update/apply, POST /update/rollback, GET /update/logs) - backend/models.py: Pydantic models for update API responses - backend/settings.py: Update config (status/log file paths) - scripts/rollback_client.sh: Rollback script for failed updates - scripts/update_client.sh: Enhanced update client script - CLAUDE.md: Documentation for future Claude Code instances Complete US_000026-028 and TASK_000027-029: - US_000026: Client pulls updates from remote service - US_000027: Client verifies and applies updates atomically - US_000028: Client reports update status to backend All endpoints require authentication. Updates run asynchronously. Documentation updated per SOP (CHANGELOG, PROJECT_STATUS, stories/tasks). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 4 + CLAUDE.md | 145 ++++++++++++++++++ backend/app.py | 74 ++++++++- backend/models.py | 29 ++++ backend/settings.py | 6 + backend/update.py | 142 +++++++++++++++++ project-management/PROJECT_STATUS.md | 12 +- .../requirements/stories/US_000026.md | 4 +- .../requirements/stories/US_000027.md | 4 +- .../requirements/stories/US_000028.md | 4 +- .../requirements/tasks/TASK_000027.md | 2 +- .../requirements/tasks/TASK_000028.md | 2 +- .../requirements/tasks/TASK_000029.md | 2 +- scripts/rollback_client.sh | 76 +++++++++ scripts/update_client.sh | 43 +++++- 15 files changed, 530 insertions(+), 19 deletions(-) create mode 100644 CLAUDE.md create mode 100644 backend/update.py create mode 100755 scripts/rollback_client.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index f4981b5..fab9693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,10 @@ 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 | --- ## 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/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 73a2ba7..8d7663a 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -45,6 +45,12 @@ class Settings: 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/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/project-management/PROJECT_STATUS.md b/project-management/PROJECT_STATUS.md index 6e8235c..7c952f7 100644 --- a/project-management/PROJECT_STATUS.md +++ b/project-management/PROJECT_STATUS.md @@ -86,12 +86,12 @@ 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 - [ ] US_000029: Update-Status im Web-UI anzeigen - [ ] TASK_000030: UI update status view - [ ] US_000030: Update-Check im Web-UI ausloesen diff --git a/project-management/requirements/stories/US_000026.md b/project-management/requirements/stories/US_000026.md index a0b71c8..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: In Progress +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 f0fd242..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: In Progress +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 fef2260..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: In Progress +Status: Done Als Betreiber moechte ich Statusmeldungen vom Client erhalten, damit Update-Ergebnisse nachvollziehbar sind. diff --git a/project-management/requirements/tasks/TASK_000027.md b/project-management/requirements/tasks/TASK_000027.md index 35f6b95..b400851 100644 --- a/project-management/requirements/tasks/TASK_000027.md +++ b/project-management/requirements/tasks/TASK_000027.md @@ -1,4 +1,4 @@ -ID: TASK_000027 | Version: 0.1.0 | Status: In Progress +ID: TASK_000027 | Version: 0.1.0 | Status: Done By: Codex (GPT-5) # TASK_000027: Update endpoint config diff --git a/project-management/requirements/tasks/TASK_000028.md b/project-management/requirements/tasks/TASK_000028.md index fa055da..0cc4207 100644 --- a/project-management/requirements/tasks/TASK_000028.md +++ b/project-management/requirements/tasks/TASK_000028.md @@ -1,4 +1,4 @@ -ID: TASK_000028 | Version: 0.1.0 | Status: In Progress +ID: TASK_000028 | Version: 0.1.0 | Status: Done By: Codex (GPT-5) # TASK_000028: Verify and apply update diff --git a/project-management/requirements/tasks/TASK_000029.md b/project-management/requirements/tasks/TASK_000029.md index 39fdd76..0b1d02c 100644 --- a/project-management/requirements/tasks/TASK_000029.md +++ b/project-management/requirements/tasks/TASK_000029.md @@ -1,4 +1,4 @@ -ID: TASK_000029 | Version: 0.1.0 | Status: In Progress +ID: TASK_000029 | Version: 0.1.0 | Status: Done By: Codex (GPT-5) # TASK_000029: Report update status 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 index 2e0195d..d3b4782 100755 --- a/scripts/update_client.sh +++ b/scripts/update_client.sh @@ -5,6 +5,8 @@ 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="" @@ -20,6 +22,35 @@ report_status() { 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 <&2 - report_status "failed" "checksum mismatch" + 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 @@ -121,7 +154,9 @@ STAGING_DIR="" log "Starting service ${SERVICE_NAME}..." if sudo systemctl start "${SERVICE_NAME}.service"; then log "Update applied successfully." - report_status "success" "" + 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}" @@ -129,6 +164,8 @@ else sudo mv "${BACKUP_DIR}" "${INSTALL_DIR}" fi sudo systemctl start "${SERVICE_NAME}.service" || true - report_status "failed" "service start failed" + 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