feat: implement update backend API and client logic

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 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 11:18:34 +01:00
parent b64cc5981c
commit 107cdabe8d
15 changed files with 530 additions and 19 deletions

View File

@ -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-API Endpunkte dokumentiert. By: Codex (GPT-5) |
| 28.12.2025 | 📝 Req | ID: Update-Status/Log Dateien 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) | | 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 ## 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

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

@ -45,6 +45,12 @@ class Settings:
self.update_status_url: str = os.getenv( self.update_status_url: str = os.getenv(
"SKD_UPDATE_STATUS_URL", "https://update.wlkns.org/status" "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")

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

View File

@ -86,12 +86,12 @@ 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
- [ ] US_000029: Update-Status im Web-UI anzeigen - [ ] US_000029: Update-Status im Web-UI anzeigen
- [ ] TASK_000030: UI update status view - [ ] TASK_000030: UI update status view
- [ ] US_000030: Update-Check im Web-UI ausloesen - [ ] US_000030: Update-Check im Web-UI ausloesen

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: 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. 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: In Progress 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: In Progress 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

@ -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) By: Codex (GPT-5)
# TASK_000027: Update endpoint config # TASK_000027: Update endpoint config

View File

@ -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) By: Codex (GPT-5)
# TASK_000028: Verify and apply update # TASK_000028: Verify and apply update

View File

@ -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) By: Codex (GPT-5)
# TASK_000029: Report update status # TASK_000029: Report update status

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

View File

@ -5,6 +5,8 @@ SERVICE_NAME="${SERVICE_NAME:-skd}"
UPDATE_URL="${SKD_UPDATE_URL:-https://update.wlkns.org}" UPDATE_URL="${SKD_UPDATE_URL:-https://update.wlkns.org}"
UPDATE_TOKEN="${SKD_UPDATE_TOKEN:-}" UPDATE_TOKEN="${SKD_UPDATE_TOKEN:-}"
STATUS_URL="${SKD_UPDATE_STATUS_URL:-https://update.wlkns.org/status}" 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}" INSTALL_DIR="${INSTALL_DIR:-/opt/sk}"
STAGING_DIR="" STAGING_DIR=""
MANIFEST_FILE="" MANIFEST_FILE=""
@ -20,6 +22,35 @@ report_status() {
local error="${2:-}" local error="${2:-}"
local timestamp local timestamp
timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" 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 local payload
payload=$(cat <<JSON payload=$(cat <<JSON
{"device_id":"$(hostname)","version":"${VERSION}","status":"${status}","error":"${error}","timestamp":"${timestamp}"} {"device_id":"$(hostname)","version":"${VERSION}","status":"${status}","error":"${error}","timestamp":"${timestamp}"}
@ -99,7 +130,9 @@ log "Verifying checksum..."
CALC_SHA="$(sha256sum "${ARTIFACT_FILE}" | awk '{print $1}')" CALC_SHA="$(sha256sum "${ARTIFACT_FILE}" | awk '{print $1}')"
if [[ "${CALC_SHA}" != "${ARTIFACT_SHA256}" ]]; then if [[ "${CALC_SHA}" != "${ARTIFACT_SHA256}" ]]; then
echo "Checksum mismatch: expected ${ARTIFACT_SHA256} got ${CALC_SHA}" >&2 echo "Checksum mismatch: expected ${ARTIFACT_SHA256} got ${CALC_SHA}" >&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 exit 1
fi fi
@ -121,7 +154,9 @@ STAGING_DIR=""
log "Starting service ${SERVICE_NAME}..." log "Starting service ${SERVICE_NAME}..."
if sudo systemctl start "${SERVICE_NAME}.service"; then if sudo systemctl start "${SERVICE_NAME}.service"; then
log "Update applied successfully." 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 else
log "Service failed to start, rolling back..." log "Service failed to start, rolling back..."
sudo rm -rf "${INSTALL_DIR}" sudo rm -rf "${INSTALL_DIR}"
@ -129,6 +164,8 @@ else
sudo mv "${BACKUP_DIR}" "${INSTALL_DIR}" sudo mv "${BACKUP_DIR}" "${INSTALL_DIR}"
fi fi
sudo systemctl start "${SERVICE_NAME}.service" || true 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 exit 1
fi fi