Merge feature/update-client: Complete EPIC_000008 Client-Side Update Mechanism

This commit is contained in:
2025-12-30 11:38:54 +01:00
32 changed files with 1279 additions and 16 deletions

View File

@ -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-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: 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: 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 ## Legende

145
CLAUDE.md Normal file
View File

@ -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 <host-name>
# 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.

View File

@ -32,6 +32,13 @@ Set in `/etc/skd/env` (see `env.example`):
- `SKD_DEFAULT_COUNTDOWN`, `SKD_DEFAULT_SOUND`, `SKD_NOTIFY_TIMEOUT`: behavior defaults. - `SKD_DEFAULT_COUNTDOWN`, `SKD_DEFAULT_SOUND`, `SKD_NOTIFY_TIMEOUT`: behavior defaults.
- `SKD_DRY_RUN=true` to test without real account changes or shutdown. - `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. - `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: Notes:
- `./scripts/install.sh` will create `/etc/skd/env` from `env.example` if missing (edit afterwards) and ensure the `skd` service user/group exist. - `./scripts/install.sh` will create `/etc/skd/env` from `env.example` if missing (edit afterwards) and ensure the `skd` service user/group exist.

View File

@ -15,9 +15,20 @@ from backend.auth import (
issue_token, issue_token,
list_manageable_users, 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.oidc import OIDCClient, OIDCError
from backend.settings import Settings, get_settings from backend.settings import Settings, get_settings
from backend import update
logging.basicConfig( logging.basicConfig(
level=logging.INFO, 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) @app.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse: def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse("index.html", {"request": request}) return templates.TemplateResponse("index.html", {"request": request})

View File

@ -34,3 +34,32 @@ class LoginRequest(BaseModel):
class LoginResponse(BaseModel): class LoginResponse(BaseModel):
token: str token: str
expires_in: int 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

View File

@ -39,6 +39,18 @@ class Settings:
self.default_sound: bool = os.getenv("SKD_DEFAULT_SOUND", "false").lower() == "true" self.default_sound: bool = os.getenv("SKD_DEFAULT_SOUND", "false").lower() == "true"
self.notify_timeout: int = int(os.getenv("SKD_NOTIFY_TIMEOUT", "5")) self.notify_timeout: int = int(os.getenv("SKD_NOTIFY_TIMEOUT", "5"))
self.dry_run: bool = os.getenv("SKD_DRY_RUN", "false").lower() == "true" 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 # Paths/tools
self.notify_send_path: str = os.getenv("SKD_NOTIFY_SEND_PATH", "notify-send") self.notify_send_path: str = os.getenv("SKD_NOTIFY_SEND_PATH", "notify-send")
self.sound_player: str = os.getenv("SKD_SOUND_PLAYER", "paplay") self.sound_player: str = os.getenv("SKD_SOUND_PLAYER", "paplay")

View File

@ -84,6 +84,27 @@
<div id="result" class="log"></div> <div id="result" class="log"></div>
</section> </section>
<section>
<h3>Update-Verwaltung</h3>
<div id="updateStatus" class="log" style="margin-bottom: 1rem; padding: 1rem; background: var(--pico-card-background-color); border-radius: var(--pico-border-radius);">
Lade Update-Status...
</div>
<div class="grid">
<button id="checkUpdateBtn" type="button">Nach Updates suchen</button>
<button id="applyUpdateBtn" type="button" disabled>Update installieren</button>
<button id="rollbackBtn" type="button">Rollback durchführen</button>
</div>
<div id="updateResult" class="log" style="margin-top: 1rem;"></div>
<details style="margin-top: 1.5rem;">
<summary>Update-Logs anzeigen</summary>
<button id="refreshLogsBtn" type="button" style="margin-top: 0.5rem;">Logs neu laden</button>
<div id="updateLogs" class="log" style="margin-top: 1rem; max-height: 400px; overflow-y: auto;"></div>
</details>
</section>
<script> <script>
const statusDiv = document.getElementById('status'); const statusDiv = document.getElementById('status');
const resultDiv = document.getElementById('result'); const resultDiv = document.getElementById('result');
@ -221,6 +242,169 @@
checkSession(); checkSession();
checkOidcStatus(); checkOidcStatus();
// ==================== Update Management ====================
const updateStatusDiv = document.getElementById('updateStatus');
const updateResultDiv = document.getElementById('updateResult');
const updateLogsDiv = document.getElementById('updateLogs');
const checkUpdateBtn = document.getElementById('checkUpdateBtn');
const applyUpdateBtn = document.getElementById('applyUpdateBtn');
const rollbackBtn = document.getElementById('rollbackBtn');
const refreshLogsBtn = document.getElementById('refreshLogsBtn');
let latestUpdateCheck = null;
async function refreshUpdateStatus() {
try {
const data = await api('/update/status');
const statusText = `
Version: ${data.current_version}
Letzter Status: ${data.last_status || 'unbekannt'}
${data.last_error ? `Fehler: ${data.last_error}` : ''}
${data.last_timestamp ? `Zeitstempel: ${new Date(data.last_timestamp).toLocaleString('de-DE')}` : ''}
`.trim();
updateStatusDiv.textContent = statusText;
} catch (err) {
updateStatusDiv.textContent = `Fehler beim Laden des Update-Status: ${err.message}`;
}
}
checkUpdateBtn.addEventListener('click', async () => {
updateResultDiv.textContent = 'Prüfe auf Updates...';
checkUpdateBtn.disabled = true;
try {
const data = await api('/update/check', { method: 'POST' });
latestUpdateCheck = data;
if (data.available) {
updateResultDiv.textContent = `
✅ Update verfügbar!
Version: ${data.latest_version}
${data.message ? `Info: ${data.message}` : ''}
Klicke auf "Update installieren" um fortzufahren.
`.trim();
applyUpdateBtn.disabled = false;
} else {
updateResultDiv.textContent = `✓ Keine Updates verfügbar. Aktuelle Version ist aktuell.`;
applyUpdateBtn.disabled = true;
}
} catch (err) {
updateResultDiv.textContent = `❌ Fehler beim Update-Check: ${err.message}`;
applyUpdateBtn.disabled = true;
} finally {
checkUpdateBtn.disabled = false;
}
});
applyUpdateBtn.addEventListener('click', async () => {
if (!latestUpdateCheck || !latestUpdateCheck.available) {
updateResultDiv.textContent = '❌ Bitte zuerst nach Updates suchen.';
return;
}
const confirmed = confirm(
`Update auf Version ${latestUpdateCheck.latest_version} installieren?\n\n` +
`⚠️ WICHTIG:\n` +
`- Ein Backup wird automatisch erstellt\n` +
`- Der Service wird neu gestartet\n` +
`- Bei Fehlern erfolgt automatischer Rollback\n\n` +
`Fortfahren?`
);
if (!confirmed) return;
updateResultDiv.textContent = 'Update wird gestartet... (läuft im Hintergrund)';
applyUpdateBtn.disabled = true;
try {
const data = await api('/update/apply', {
method: 'POST',
body: JSON.stringify({ version: latestUpdateCheck.latest_version })
});
updateResultDiv.textContent = `
✓ ${data.message}
Das Update läuft jetzt im Hintergrund.
Aktualisiere den Status in wenigen Sekunden, um den Fortschritt zu sehen.
`.trim();
// Auto-refresh nach 5 Sekunden
setTimeout(() => {
refreshUpdateStatus();
applyUpdateBtn.disabled = true;
}, 5000);
} catch (err) {
updateResultDiv.textContent = `❌ Fehler beim Starten des Updates: ${err.message}`;
applyUpdateBtn.disabled = false;
}
});
rollbackBtn.addEventListener('click', async () => {
const confirmed = confirm(
`Rollback zum letzten Backup durchführen?\n\n` +
`⚠️ WICHTIG:\n` +
`- Dies stellt die vorherige Version wieder her\n` +
`- Der Service wird neu gestartet\n` +
`- Ein Backup muss vorhanden sein\n\n` +
`Fortfahren?`
);
if (!confirmed) return;
updateResultDiv.textContent = 'Rollback wird gestartet... (läuft im Hintergrund)';
rollbackBtn.disabled = true;
try {
const data = await api('/update/rollback', { method: 'POST' });
updateResultDiv.textContent = `
✓ ${data.message}
Der Rollback läuft jetzt im Hintergrund.
Aktualisiere den Status in wenigen Sekunden.
`.trim();
// Auto-refresh nach 5 Sekunden
setTimeout(() => {
refreshUpdateStatus();
}, 5000);
} catch (err) {
updateResultDiv.textContent = `❌ Fehler beim Rollback: ${err.message}`;
} finally {
rollbackBtn.disabled = false;
}
});
async function refreshUpdateLogs() {
updateLogsDiv.textContent = 'Lade Logs...';
try {
const logs = await api('/update/logs');
if (!logs || logs.length === 0) {
updateLogsDiv.textContent = 'Keine Logs vorhanden.';
return;
}
// Reverse chronological (newest first)
const logEntries = logs.reverse().map(entry => {
const timestamp = entry.timestamp ? new Date(entry.timestamp).toLocaleString('de-DE') : 'unbekannt';
const status = entry.status || 'unknown';
const version = entry.version || '-';
const error = entry.error ? `\n Fehler: ${entry.error}` : '';
return `[${timestamp}] ${status} - Version: ${version}${error}`;
});
updateLogsDiv.textContent = logEntries.join('\n\n');
} catch (err) {
updateLogsDiv.textContent = `Fehler beim Laden der Logs: ${err.message}`;
}
}
refreshLogsBtn.addEventListener('click', refreshUpdateLogs);
// Initial load
refreshUpdateStatus();
</script> </script>
</body> </body>
</html> </html>

142
backend/update.py Normal file
View File

@ -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

60
docs/update-api.md Normal file
View File

@ -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": "<optional>",
"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": "<hex>",
"message": "<optional>"
}
```
### 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.

49
docs/update-client.md Normal file
View File

@ -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": "<hex>",
"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).

25
docs/update-status.md Normal file
View File

@ -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": "<hostname>",
"version": "0.1.2",
"status": "success|failed",
"error": "<optional message>",
"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.

View File

@ -21,5 +21,12 @@ SKD_OIDC_STATE_COOKIE_NAME=skd_oidc_state
SKD_DEFAULT_COUNTDOWN=60 SKD_DEFAULT_COUNTDOWN=60
SKD_DEFAULT_SOUND=false SKD_DEFAULT_SOUND=false
SKD_NOTIFY_TIMEOUT=5 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 # Set to true to test without performing real system changes
SKD_DRY_RUN=false SKD_DRY_RUN=false

View File

@ -10,9 +10,9 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
✅ Stabilization ✅ Stabilization
## Aktueller Fokus ## Aktueller Fokus
1. Dokumentierter Ist-Zustand der Module. 1. Client-Update-Mechanik planen (EPIC_000008).
2. Pflege der Anforderungen bei neuen Features. 2. Dokumentierter Ist-Zustand der Module.
3. Doku und Ops-Automation aktuell halten. 3. Pflege der Anforderungen bei neuen Features.
## Projekt-Tagebuch (Kurz, optional) ## Projekt-Tagebuch (Kurz, optional)
| Datum | Typ | Beschreibung | | Datum | Typ | Beschreibung |
@ -86,12 +86,22 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
- [x] TASK_000023: README runbook notes - [x] TASK_000023: README runbook notes
### EPIC_000008: Client-Side Update Mechanism ### EPIC_000008: Client-Side Update Mechanism
- [ ] US_000026: Client bezieht Updates (Pull) - [x] US_000026: Client bezieht Updates (Pull)
- [ ] TASK_000027: Update endpoint config - [x] TASK_000027: Update endpoint config
- [ ] US_000027: Client verifiziert und wendet Updates an - [x] US_000027: Client verifiziert und wendet Updates an
- [ ] TASK_000028: Verify and apply update - [x] TASK_000028: Verify and apply update
- [ ] US_000028: Client meldet Update-Status - [x] US_000028: Client meldet Update-Status
- [ ] TASK_000029: Report 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) ### EPIC_000009: Update Webservice (External Team)
- [ ] US_000026: Client bezieht Updates (Pull) - [ ] US_000026: Client bezieht Updates (Pull)

View File

@ -39,3 +39,8 @@ Ermoegliche einen robusten Client-Update-Flow mit Verifikation und Rollback.
- US_000026: Client bezieht Updates (Pull) - US_000026: Client bezieht Updates (Pull)
- US_000027: Client verifiziert und wendet Updates an - US_000027: Client verifiziert und wendet Updates an
- US_000028: Client meldet Update-Status - 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

View File

@ -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) By: Codex (GPT-5)
# US_000026: Client bezieht Updates (Pull) # 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. Als Betreiber moechte ich, dass der Client Updates per Pull von einem Update-Service bezieht, damit Deployments ohne SSH moeglich sind.

View File

@ -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) By: Codex (GPT-5)
# US_000027: Client verifiziert und wendet Updates an # 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. Als Betreiber moechte ich, dass der Client Updates verifiziert und sicher anwendet, damit fehlerhafte Pakete keine Ausfaelle verursachen.

View File

@ -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) By: Codex (GPT-5)
# US_000028: Client meldet Update-Status # US_000028: Client meldet Update-Status
Status: Draft Status: Done
Als Betreiber moechte ich Statusmeldungen vom Client erhalten, damit Update-Ergebnisse nachvollziehbar sind. Als Betreiber moechte ich Statusmeldungen vom Client erhalten, damit Update-Ergebnisse nachvollziehbar sind.

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

76
scripts/rollback_client.sh Executable file
View File

@ -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

171
scripts/update_client.sh Executable file
View File

@ -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 <<JSON
{"device_id":"$(hostname)","version":"${VERSION}","status":"${status}","error":"${error}","timestamp":"${timestamp}"}
JSON
)
if [[ -n "${UPDATE_TOKEN}" ]]; then
curl -sS -X POST -H "Authorization: Bearer ${UPDATE_TOKEN}" -H "Content-Type: application/json" \
-d "${payload}" "${STATUS_URL}" >/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