feat: implement update-service v1 migration and enrollment flow

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,91 @@
ID: DOC_000006 | Version: 0.1.0 | Status: Draft
# Admin Token Operations
## Purpose
This document describes how operators create and manage pre-shared enrollment tokens for clients.
## Pre-Shared Token Creation
Operators generate a single-use enrollment token and share it out-of-band with the client.
Recommended properties:
- Single-use only
- Short TTL (e.g., 24h)
- Scoped to `project_id` and optional `client_id`/`software_id`
## Admin Interfaces
We provide both an Admin API and a CLI tool for token operations. A frontend will be added later.
### Admin User and Access
- An admin user must exist to operate token workflows.
- Initial access uses a local admin token.
- Later, admin auth will be integrated with the OIDC service.
### CLI and Admin API Capabilities
- Create enrollment tokens
- List token metadata (no plaintext output)
- Revoke tokens
- Export a token as a file for client installation
### Local Admin Token (Initial Phase)
- Admin requests must include `Authorization: Bearer <ADMIN_TOKEN>`.
- The admin token is stored locally (e.g., `.env`) and never committed.
Example `.env` (local only):
```
ADMIN_TOKEN=change-me-please
```
Minimal flow (first token):
1) Set `ADMIN_TOKEN` in `.env`.
2) Call `POST /v1/admin/enrollment-tokens` with the bearer token.
3) Export the returned one-time token to a file and hand it to the client.
## Admin API (Draft)
All admin endpoints are authenticated. Initial auth is local; later OIDC.
Base path:
- `/v1/admin`
Endpoints:
- `POST /v1/admin/enrollment-tokens`
- Create a pre-shared enrollment token.
- Request: `project_id`, optional `client_id`, optional `software_id`, optional `expires_at`.
- Response: token metadata + one-time plaintext token.
- `GET /v1/admin/enrollment-tokens`
- List token metadata (never return plaintext tokens).
- Supports filtering by `project_id`, `client_id`, `status` (active/used/expired).
- `POST /v1/admin/enrollment-tokens/{token_id}/revoke`
- Revoke a token (marks as revoked or sets `used_at`/`revoked_at`).
- `GET /v1/admin/enrollment-tokens/{token_id}/export`
- Export the one-time token to a file download (single use).
## CLI (Draft)
Example commands (names can be adjusted):
- `update-service admin token create --project <id> [--client <id>] [--software <id>] [--expires <iso8601>]`
- `update-service admin token list --project <id> [--status active|used|expired|revoked]`
- `update-service admin token revoke --id <token_id>`
- `update-service admin token export --id <token_id> --out ./enroll-token.txt`
Example format:
```
enroll_<random_32_bytes>
```
## Storage and Safety
- Store only a hash of the enrollment token (never plaintext).
- Track `created_at`, `expires_at`, and `used_at`.
- Deny enrollment if `expires_at` is exceeded or `used_at` is set.
## Rotation and Revocation
- Revoke enrollment tokens by invalidating their stored hash.
- Issue a new enrollment token if the previous one expires or is leaked.
## Distribution
Preferred channels:
- One-time install code (copy/paste)
- QR code
- Encrypted file included in an install bundle
## Audit Expectations
- Log token creation and enrollment usage for traceability.

View File

@ -0,0 +1,57 @@
openapi: 3.0.3
info:
title: Update Webservice API
version: 0.1.0
servers:
- url: https://update.wlkns.org
- url: https://staging.update.wlkns.org
security:
- bearerAuth: []
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
Manifest:
$ref: './openapi/schemas/manifest.yaml'
StatusReport:
$ref: './openapi/schemas/status-report.yaml'
UploadResponse:
$ref: './openapi/schemas/upload-response.yaml'
EnrollRequest:
$ref: './openapi/schemas/enroll-request.yaml'
EnrollResponse:
$ref: './openapi/schemas/enroll-response.yaml'
EnrollmentToken:
$ref: './openapi/schemas/enrollment-token.yaml'
EnrollmentTokenCreateRequest:
$ref: './openapi/schemas/enrollment-token-create-request.yaml'
EnrollmentTokenCreateResponse:
$ref: './openapi/schemas/enrollment-token-create-response.yaml'
Error:
$ref: './openapi/schemas/error.yaml'
Limits:
$ref: './openapi/schemas/limits.yaml'
LimitsPolicy:
$ref: './openapi/schemas/limits-policy.yaml'
paths:
/v1/enroll:
$ref: './openapi/paths/enroll.yaml'
/v1/admin/enrollment-tokens:
$ref: './openapi/paths/admin-enrollment-tokens.yaml'
/v1/admin/enrollment-tokens/{token_id}/revoke:
$ref: './openapi/paths/admin-enrollment-tokens-revoke.yaml'
/v1/admin/enrollment-tokens/{token_id}/export:
$ref: './openapi/paths/admin-enrollment-tokens-export.yaml'
/v1/projects/{project_id}/manifest:
$ref: './openapi/paths/manifest.yaml'
/v1/projects/{project_id}/releases/{version}/artifact:
$ref: './openapi/paths/artifact.yaml'
/v1/projects/{project_id}/status:
$ref: './openapi/paths/status.yaml'
/v1/projects/{project_id}/releases:
$ref: './openapi/paths/releases.yaml'
/v1/limits:
$ref: './openapi/paths/limits.yaml'

View File

@ -0,0 +1,31 @@
get:
summary: Export enrollment token
x-auth-scopes: [admin]
parameters:
- name: token_id
in: path
required: true
schema:
type: string
responses:
'200':
description: Token file
content:
text/plain:
schema:
type: string
example: enroll_6f3d2c...
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
'404':
description: Not Found
x-error-codes: [not_found]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'

View File

@ -0,0 +1,30 @@
post:
summary: Revoke enrollment token
x-auth-scopes: [admin]
parameters:
- name: token_id
in: path
required: true
schema:
type: string
responses:
'200':
description: Revoked
content:
application/json:
schema:
$ref: '../schemas/enrollment-token.yaml'
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
'404':
description: Not Found
x-error-codes: [not_found]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'

View File

@ -0,0 +1,69 @@
get:
summary: List enrollment tokens
x-auth-scopes: [admin]
parameters:
- name: project_id
in: query
required: false
schema:
type: string
- name: client_id
in: query
required: false
schema:
type: string
- name: status
in: query
required: false
schema:
type: string
enum: [active, used, expired, revoked]
responses:
'200':
description: Token list
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: '../schemas/enrollment-token.yaml'
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
post:
summary: Create enrollment token
x-auth-scopes: [admin]
requestBody:
required: true
content:
application/json:
schema:
$ref: '../schemas/enrollment-token-create-request.yaml'
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '../schemas/enrollment-token-create-response.yaml'
'400':
description: Bad Request
x-error-codes: [invalid_payload]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'

View File

@ -0,0 +1,58 @@
get:
summary: Download artifact
x-auth-scopes: [read_manifest]
parameters:
- name: project_id
in: path
required: true
schema:
type: string
- name: version
in: path
required: true
schema:
type: string
responses:
'200':
description: Artifact tar.gz
content:
application/gzip:
schema:
type: string
format: binary
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Missing or invalid token
'404':
description: Not Found
x-error-codes: [not_found]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
not_found:
value:
code: not_found
message: Artifact not found
'429':
description: Too Many Requests
x-error-codes: [rate_limited]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
rate_limited:
value:
code: rate_limited
message: Too many requests

View File

@ -0,0 +1,65 @@
post:
summary: Enroll client and issue long-term token
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '../schemas/enroll-request.yaml'
examples:
enroll:
value:
project_id: demo
client_id: device-42
software_id: kiosk
enroll_token: enroll_6f3d2c...
responses:
'200':
description: Enrollment successful
content:
application/json:
schema:
$ref: '../schemas/enroll-response.yaml'
examples:
issued:
value:
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
scope: read_manifest report_status
expires_at: 2026-12-30T10:00:00Z
'400':
description: Bad Request
x-error-codes: [invalid_payload]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
invalid_payload:
value:
code: invalid_payload
message: Missing required fields
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Invalid or expired enrollment token
'409':
description: Conflict
x-error-codes: [already_enrolled]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
already_enrolled:
value:
code: already_enrolled
message: Client already enrolled

View File

@ -0,0 +1,48 @@
get:
summary: Get service limits
x-auth-scopes: [read_manifest]
responses:
'200':
description: Limits
content:
application/json:
schema:
$ref: '../schemas/limits-policy.yaml'
examples:
medium:
value:
tier: medium
limits:
upload_max_artifact_size_bytes_soft: 1073741824
upload_max_artifact_size_bytes_hard: 2147483648
read_max_requests_per_minute_soft: 300
read_max_requests_per_minute_hard: 600
upload_max_requests_per_minute_soft: 6
upload_max_requests_per_minute_hard: 12
report_max_requests_per_minute_soft: 120
report_max_requests_per_minute_hard: 240
burst_requests_per_minute: 1200
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Missing or invalid token
'429':
description: Too Many Requests
x-error-codes: [rate_limited]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
rate_limited:
value:
code: rate_limited
message: Too many requests

View File

@ -0,0 +1,47 @@
get:
summary: Get active manifest
x-auth-scopes: [read_manifest]
parameters:
- name: project_id
in: path
required: true
schema:
type: string
responses:
'200':
description: Manifest
content:
application/json:
schema:
$ref: '../schemas/manifest.yaml'
examples:
default:
value:
version: 1.2.3
artifact_url: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/artifact
sha256: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
sig_url: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/signature
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Missing or invalid token
'429':
description: Too Many Requests
x-error-codes: [rate_limited]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
rate_limited:
value:
code: rate_limited
message: Too many requests

View File

@ -0,0 +1,126 @@
post:
summary: Upload release
x-auth-scopes: [upload_release]
parameters:
- name: project_id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- version
- sha256
- artifact
properties:
version:
type: string
pattern: '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
example: 1.2.3
sha256:
type: string
example: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
sig_url:
type: string
format: uri
description: Optional reference to a detached signature
signature:
type: string
format: binary
description: Detached signature file (optional alternative to sig_url)
key_id:
type: string
description: Public key identifier for signature verification
artifact:
type: string
format: binary
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '../schemas/upload-response.yaml'
examples:
created:
value:
version: 1.2.3
manifest_url: https://update.wlkns.org/v1/projects/demo/manifest
active: true
'400':
description: Bad Request
x-error-codes: [invalid_payload]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
invalid_payload:
value:
code: invalid_payload
message: Missing required fields
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Missing or invalid token
'409':
description: Conflict
x-error-codes: [version_exists]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
version_exists:
value:
code: version_exists
message: Version already exists
'413':
description: Payload Too Large
x-error-codes: [payload_too_large]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
payload_too_large:
value:
code: payload_too_large
message: Artifact exceeds size limit
'422':
description: Unprocessable Entity (invalid checksum/signature/version)
x-error-codes: [checksum_mismatch, signature_invalid, signature_missing, version_invalid]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
checksum_mismatch:
value:
code: checksum_mismatch
message: SHA256 does not match artifact
'429':
description: Too Many Requests
x-error-codes: [rate_limited]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
rate_limited:
value:
code: rate_limited
message: Too many requests

View File

@ -0,0 +1,84 @@
post:
summary: Report update status
x-auth-scopes: [report_status]
parameters:
- name: project_id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '../schemas/status-report.yaml'
examples:
success:
value:
project_id: demo
version: 1.2.3
status: success
timestamp: 2025-12-28T10:15:30Z
client_id: device-42
duration_ms: 2450
failure:
value:
project_id: demo
version: 1.2.3
status: failed
timestamp: 2025-12-28T10:15:30Z
client_id: device-42
reason: checksum_mismatch
error_code: checksum_mismatch
responses:
'202':
description: Accepted
'400':
description: Bad Request
x-error-codes: [invalid_payload]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
invalid_payload:
value:
code: invalid_payload
message: Missing required fields
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Missing or invalid token
'422':
description: Unprocessable Entity (invalid version or status)
x-error-codes: [version_invalid, status_invalid]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
version_invalid:
value:
code: version_invalid
message: Version does not match SemVer
'429':
description: Too Many Requests
x-error-codes: [rate_limited]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
rate_limited:
value:
code: rate_limited
message: Too many requests

View File

@ -0,0 +1,20 @@
type: object
required:
- project_id
- client_id
- software_id
- enroll_token
properties:
project_id:
type: string
example: demo
client_id:
type: string
example: device-42
software_id:
type: string
example: kiosk
enroll_token:
type: string
description: Pre-shared, single-use enrollment token
example: enroll_6f3d2c...

View File

@ -0,0 +1,19 @@
type: object
required:
- token
- scope
properties:
token:
type: string
description: Long-term bearer token for client requests
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
scope:
type: string
description: Space-delimited scopes
example: read_manifest report_status
expires_at:
type: string
format: date-time
nullable: true
description: Null for non-expiring tokens
example: 2026-12-30T10:00:00Z

View File

@ -0,0 +1,20 @@
type: object
required:
- project_id
properties:
project_id:
type: string
example: demo
client_id:
type: string
nullable: true
example: device-42
software_id:
type: string
nullable: true
example: kiosk
expires_at:
type: string
format: date-time
nullable: true
example: 2026-12-30T10:00:00Z

View File

@ -0,0 +1,11 @@
type: object
required:
- token
- token_meta
properties:
token:
type: string
description: One-time plaintext enrollment token
example: enroll_6f3d2c...
token_meta:
$ref: './enrollment-token.yaml'

View File

@ -0,0 +1,39 @@
type: object
required:
- id
- project_id
- status
- created_at
properties:
id:
type: string
example: tok_123
project_id:
type: string
example: demo
client_id:
type: string
nullable: true
example: device-42
software_id:
type: string
nullable: true
example: kiosk
status:
type: string
enum: [active, used, expired, revoked]
example: active
expires_at:
type: string
format: date-time
nullable: true
example: 2026-12-30T10:00:00Z
created_at:
type: string
format: date-time
example: 2025-12-30T10:00:00Z
used_at:
type: string
format: date-time
nullable: true
example: 2025-12-30T10:15:00Z

View File

@ -0,0 +1,15 @@
type: object
required:
- code
- message
properties:
code:
type: string
description: Error code (e.g., unauthorized, invalid_payload, already_enrolled)
example: unauthorized
message:
type: string
example: Missing or invalid token
details:
type: object
additionalProperties: true

View File

@ -0,0 +1,10 @@
type: object
required:
- tier
- limits
properties:
tier:
type: string
enum: [small, medium, large]
limits:
$ref: './limits.yaml'

View File

@ -0,0 +1,39 @@
type: object
required:
- upload_max_artifact_size_bytes_soft
- upload_max_artifact_size_bytes_hard
- read_max_requests_per_minute_soft
- read_max_requests_per_minute_hard
- upload_max_requests_per_minute_soft
- upload_max_requests_per_minute_hard
- report_max_requests_per_minute_soft
- report_max_requests_per_minute_hard
- burst_requests_per_minute
properties:
upload_max_artifact_size_bytes_soft:
type: integer
default: 1073741824
upload_max_artifact_size_bytes_hard:
type: integer
default: 2147483648
read_max_requests_per_minute_soft:
type: integer
default: 300
read_max_requests_per_minute_hard:
type: integer
default: 600
upload_max_requests_per_minute_soft:
type: integer
default: 6
upload_max_requests_per_minute_hard:
type: integer
default: 12
report_max_requests_per_minute_soft:
type: integer
default: 120
report_max_requests_per_minute_hard:
type: integer
default: 240
burst_requests_per_minute:
type: integer
default: 1200

View File

@ -0,0 +1,24 @@
type: object
required:
- version
- artifact_url
- sha256
properties:
version:
type: string
description: SemVer string (e.g., 1.2.3)
pattern: '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
example: 1.2.3
artifact_url:
type: string
format: uri
example: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/artifact
sha256:
type: string
description: Hex-encoded SHA256
example: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
sig_url:
type: string
format: uri
nullable: true
example: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/signature

View File

@ -0,0 +1,42 @@
type: object
required:
- project_id
- version
- status
- timestamp
properties:
project_id:
type: string
example: demo
version:
type: string
example: 1.2.3
status:
type: string
enum: [success, failed, in_progress]
example: success
timestamp:
type: string
format: date-time
example: 2025-12-28T10:15:30Z
reason:
type: string
example: checksum_mismatch
client_id:
type: string
example: device-42
client_version:
type: string
example: 1.2.2
device_type:
type: string
example: kiosk
update_channel:
type: string
example: stable
duration_ms:
type: integer
example: 2450
error_code:
type: string
example: checksum_mismatch

View File

@ -0,0 +1,16 @@
type: object
required:
- version
- manifest_url
properties:
version:
type: string
example: 1.2.3
manifest_url:
type: string
format: uri
example: https://update.wlkns.org/v1/projects/demo/manifest
active:
type: boolean
description: True if release is active
example: true

44
docs/client-quickstart.md Normal file
View File

@ -0,0 +1,44 @@
ID: DOC_000008 | Version: 0.1.0 | Status: Draft
# Client Quickstart
## Goal
Enroll a client, store the long-term token, fetch the manifest, and report status.
## 1) Get a Pre-Shared Token
Request a one-time enrollment token from an admin/operator.
## 2) Enroll and Receive Long-Term Token
```
curl -X POST https://update.wlkns.org/v1/enroll \
-H "Content-Type: application/json" \
-d '{
"project_id": "safe-kiddo-control",
"client_id": "kiddo-001",
"software_id": "kiddo-agent",
"enroll_token": "<pre_shared_token>"
}'
```
Store the returned token locally (file or secret store). Example:
```
echo "<long_term_token>" > ./update-token.txt
```
## 3) Fetch Manifest
```
curl -H "Authorization: Bearer $(cat ./update-token.txt)" \
https://update.wlkns.org/v1/projects/safe-kiddo-control/manifest
```
## 4) Report Status
```
curl -H "Authorization: Bearer $(cat ./update-token.txt)" \
-H "Content-Type: application/json" \
-d '{"project_id":"safe-kiddo-control","version":"0.1.2","status":"success","timestamp":"2025-12-30T10:00:00Z"}' \
https://update.wlkns.org/v1/projects/safe-kiddo-control/status
```
## Notes
- All endpoints require `Authorization: Bearer <token>` except `/v1/enroll`.
- Status values: `success`, `failed`, `in_progress`.

135
docs/third-party-api.md Normal file
View File

@ -0,0 +1,135 @@
ID: DOC_000005 | Version: 0.1.0 | Status: Draft
# Third-Party API Guide
## Purpose
This document explains how third-party services integrate with the Update Webservice: obtaining tokens, fetching manifests, downloading artifacts, and reporting status.
## Quick Start (First Client)
1) Request a pre-shared enrollment token from an admin/operator.
2) Enroll once to obtain a long-term token.
3) Store the long-term token locally and use it for all API calls.
## Base URLs
- Production: `https://update.wlkns.org`
- Staging: `https://staging.update.wlkns.org`
All endpoints are versioned under `/v1`.
## Authentication
All endpoints require `Authorization: Bearer <token>`.
### Enrollment (Pre-Shared Token -> Long-Term Token)
Clients obtain a long-term token by exchanging a pre-shared token provided by an admin/operator.
Request (example):
```
POST /v1/enroll
{
"project_id": "<project>",
"client_id": "<client>",
"software_id": "<software>",
"enroll_token": "<pre_shared_token>"
}
```
Response (example):
```
200 OK
{
"token": "<long_term_token>",
"scope": "read_manifest report_status",
"expires_at": "<iso8601 or null>"
}
```
Notes:
- Enrollment tokens are single-use and must be invalidated after a successful exchange.
- If the token is invalid or reused, the server responds with `unauthorized` or `invalid_payload`.
- Enrollment does not require an existing bearer token.
- If the client is already enrolled, the server responds with `already_enrolled` (HTTP 409).
## Client API (Read + Report)
### Get Manifest
```
GET /v1/projects/{project_id}/manifest
```
Response:
```
{
"version": "0.1.2",
"artifact_url": "https://update.wlkns.org/v1/projects/<project_id>/releases/0.1.2/artifact",
"sha256": "<hex>",
"sig_url": "<optional>"
}
```
Required scope: `read_manifest`
### Download Artifact
```
GET /v1/projects/{project_id}/releases/{version}/artifact
```
Required scope: `read_manifest`
### Report Status
```
POST /v1/projects/{project_id}/status
{
"project_id": "<project_id>",
"version": "<semver>",
"status": "success|failed|in_progress",
"timestamp": "<iso8601>",
"client_id": "<optional>",
"duration_ms": "<optional>",
"error_code": "<optional>"
}
```
Required scope: `report_status`
## Release API (Upload)
### Upload Release
```
POST /v1/projects/{project_id}/releases
Content-Type: multipart/form-data
```
Required scope: `upload_release`
Required fields:
- `version` (SemVer)
- `artifact` (file)
- `sha256` (hex)
Optional fields:
- `sig_url` or inline signature
- `key_id`
## Error Codes
Common error codes:
`unauthorized`, `rate_limited`, `not_found`, `invalid_payload`, `version_invalid`,
`version_exists`, `checksum_mismatch`, `signature_missing`, `signature_invalid`,
`payload_too_large`, `status_invalid`
## Rate Limits
Limits are tiered by scope. See `docs/architecture/ARCHITECTURE.md` for current values.
## Examples
Fetch manifest:
```
curl -H "Authorization: Bearer $TOKEN" \
https://update.wlkns.org/v1/projects/$PROJECT_ID/manifest
```
Report status:
```
curl -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"project_id":"'"$PROJECT_ID"'","version":"0.1.2","status":"success","timestamp":"2025-12-30T10:00:00Z"}' \
https://update.wlkns.org/v1/projects/$PROJECT_ID/status
```

View File

@ -108,6 +108,12 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
- [ ] US_000027: Client verifiziert und wendet Updates an
- [ ] US_000028: Client meldet Update-Status
### EPIC_000010: Update-Service v1 Migration (Major Release)
- [x] US_000034: Enrollment fuer Langzeit-Token
- [x] TASK_000040: Enrollment-Flow implementieren
- [x] US_000035: v1 Update-Endpoints und Status-Schema
- [x] TASK_000041: v1 Endpunkte im Update-Client umstellen
## Offene Risiken / Abhaengigkeiten
- Betrieb erfordert Root/sudo und lokale System-Tools (notify-send, sound player, uvicorn).
- OIDC-Validierung blockiert bis IdP bereit und Service laeuft.

View File

@ -0,0 +1,10 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- V2 App Icon (Teal Optimized) -->
<rect width="512" height="512" rx="115" fill="#1F2A37"/>
<g transform="translate(120, 96) scale(8)">
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
<!-- Teal Keystone -->
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 539 B

View File

@ -0,0 +1,9 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- V12 App Icon (Teal) -->
<rect width="512" height="512" rx="115" fill="#1F2A37"/>
<g transform="translate(120, 96) scale(8)">
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 503 B

View File

@ -0,0 +1,7 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="12" fill="#1F2A37"/>
<rect x="12" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
<rect x="40" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
<!-- Teal Keystone -->
<rect x="26" y="29" width="12" height="19" rx="2" fill="#0EA5A4"/>
</svg>

After

Width:  |  Height:  |  Size: 428 B

View File

@ -0,0 +1,15 @@
<svg width="250" height="60" viewBox="0 0 250 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Monolithic Symbol (V2 Optimized - Teal) -->
<g transform="translate(10, 10)">
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
<!-- Middle Block: Teal, 19px Height -->
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
</g>
<!-- Wordmark -->
<text x="55" y="42" fill="#1F2A37" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">WLKNS</text>
<!-- The Underline: Teal, aligned like V8 -->
<rect x="56" y="48" width="121" height="4" rx="1" fill="#0EA5A4"/>
</svg>

After

Width:  |  Height:  |  Size: 764 B

View File

@ -0,0 +1,16 @@
<svg width="340" height="60" viewBox="0 0 340 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- V12: ALL TEAL -->
<g transform="translate(10, 10)">
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
</g>
<text x="55" y="42" fill="#1F2A37" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">WLKNS</text>
<rect x="188" y="27" width="6" height="6" rx="1" fill="#4B5563"/>
<!-- AI: Teal -->
<text x="206" y="42" fill="#0EA5A4" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">AI</text>
<!-- Underline: Teal -->
<rect x="56" y="48" width="186" height="4" rx="1" fill="#0EA5A4"/>
</svg>

After

Width:  |  Height:  |  Size: 900 B

View File

@ -1,4 +1,4 @@
ID: EPIC_000010 | Version: 0.1.5 | Status: Draft
ID: EPIC_000010 | Version: 0.1.5 | Status: Done
By: Codex (GPT-5)
# EPIC_000010: Update-Service v1 Migration (Major Release)

View File

@ -1,4 +1,4 @@
ID: US_000034 | Version: 0.1.5 | Status: Draft
ID: US_000034 | Version: 0.1.5 | Status: Done
By: Codex (GPT-5)
# US_000034: Enrollment fuer Langzeit-Token

View File

@ -1,4 +1,4 @@
ID: US_000035 | Version: 0.1.5 | Status: Draft
ID: US_000035 | Version: 0.1.5 | Status: Done
By: Codex (GPT-5)
# US_000035: v1 Update-Endpoints und Status-Schema

View File

@ -1,4 +1,4 @@
ID: TASK_000040 | Version: 0.1.5 | Status: Draft
ID: TASK_000040 | Version: 0.1.5 | Status: Done
By: Codex (GPT-5)
# TASK_000040: Enrollment-Flow implementieren

View File

@ -1,4 +1,4 @@
ID: TASK_000041 | Version: 0.1.5 | Status: Draft
ID: TASK_000041 | Version: 0.1.5 | Status: Done
By: Codex (GPT-5)
# TASK_000041: v1 Endpunkte im Update-Client umstellen

View File

@ -2,6 +2,10 @@
set -euo pipefail
SERVICE_NAME="${SERVICE_NAME:-skd}"
UPDATE_SERVICE_URL="${SKD_UPDATE_SERVICE_URL:-https://update.wlkns.org}"
PROJECT_ID="${SKD_UPDATE_PROJECT_ID:-safe-kiddo-control}"
UPDATE_TOKEN="${SKD_UPDATE_TOKEN:-}"
STATUS_URL="${UPDATE_SERVICE_URL}/v1/projects/${PROJECT_ID}/status"
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}"
@ -14,6 +18,8 @@ write_status() {
local status="$1"
local error="${2:-}"
local version="$3"
local timestamp
timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
SKD_STATUS="${status}" SKD_ERROR="${error}" SKD_VERSION="${version}" \
SKD_STATUS_FILE="${STATUS_FILE}" SKD_LOG_FILE="${LOG_FILE}" python3 - <<'PY'
import json
@ -41,7 +47,25 @@ 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
{
"project_id": "${PROJECT_ID}",
"version": "${version}",
"status": "${status}",
"timestamp": "${timestamp}",
"client_id": "$(hostname)",
"error_code": "${error}",
"reason": "${error}"
}
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
fi
}
LATEST_BACKUP="$(ls -dt /opt/sk_backup_* 2>/dev/null | head -1 || true)"
if [[ -z "${LATEST_BACKUP}" ]]; then

View File

@ -2,9 +2,11 @@
set -euo pipefail
SERVICE_NAME="${SERVICE_NAME:-skd}"
UPDATE_URL="${SKD_UPDATE_URL:-https://update.wlkns.org}"
UPDATE_SERVICE_URL="${SKD_UPDATE_SERVICE_URL:-https://update.wlkns.org}"
PROJECT_ID="${SKD_UPDATE_PROJECT_ID:-safe-kiddo-control}"
UPDATE_TOKEN="${SKD_UPDATE_TOKEN:-}"
STATUS_URL="${SKD_UPDATE_STATUS_URL:-https://update.wlkns.org/status}"
MANIFEST_URL="${UPDATE_SERVICE_URL}/v1/projects/${PROJECT_ID}/manifest"
STATUS_URL="${UPDATE_SERVICE_URL}/v1/projects/${PROJECT_ID}/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}"
@ -53,7 +55,15 @@ with log_file.open("a", encoding="utf-8") as handle:
PY
local payload
payload=$(cat <<JSON
{"device_id":"$(hostname)","version":"${VERSION}","status":"${status}","error":"${error}","timestamp":"${timestamp}"}
{
"project_id": "${PROJECT_ID}",
"version": "${VERSION}",
"status": "${status}",
"timestamp": "${timestamp}",
"client_id": "$(hostname)",
"error_code": "${error}",
"reason": "${error}"
}
JSON
)
if [[ -n "${UPDATE_TOKEN}" ]]; then
@ -89,12 +99,12 @@ require_cmd tar
require_cmd sha256sum
require_cmd python3
log "Fetching update manifest from ${UPDATE_URL}..."
log "Fetching update manifest from ${MANIFEST_URL}..."
MANIFEST_FILE="$(mktemp)"
if [[ -n "${UPDATE_TOKEN}" ]]; then
curl -fsS -H "Authorization: Bearer ${UPDATE_TOKEN}" "${UPDATE_URL}" -o "${MANIFEST_FILE}"
curl -fsS -H "Authorization: Bearer ${UPDATE_TOKEN}" "${MANIFEST_URL}" -o "${MANIFEST_FILE}"
else
curl -fsS "${UPDATE_URL}" -o "${MANIFEST_FILE}"
curl -fsS "${MANIFEST_URL}" -o "${MANIFEST_FILE}"
fi
read_manifest() {