first commit

This commit is contained in:
2025-12-15 12:07:32 +01:00
commit 6f49528bbd
21 changed files with 1258 additions and 0 deletions

33
AGENTS.md Normal file
View File

@ -0,0 +1,33 @@
# Repository Guidelines
## Project Structure & Module Organization
- Root contains `sk.sh`, a Bash utility for temporarily disabling or enabling a user account with optional countdown, notifications, and shutdown logic.
- No nested modules yet; add supporting scripts under a new `scripts/` directory and keep reusable helpers in separate Bash files to avoid bloating `sk.sh`.
- Assets (e.g., custom sounds) should live under `assets/` if introduced; reference them via absolute paths in the script.
## Build, Test, and Development Commands
- `bash -n sk.sh` — Syntax check to catch parsing errors early.
- `shellcheck sk.sh` — Linting for style, safety, and portability; fix or suppress with clear rationale.
- `./sk.sh <user> disable|enable [countdown] [sound] [seconds]` — Run the tool; requires root. Use a test account when iterating.
- `sudo ./sk.sh demo_user disable countdown sound 90` — Example invocation combining optional modes.
## Coding Style & Naming Conventions
- Bash 4+; prefer `[[ ... ]]` tests, `local` inside functions, and `set -euo pipefail` for new scripts (add thoughtfully to existing files to avoid breaking flows).
- Functions: `snake_case` verbs (`send_notify`, `is_user_logged_in`). Constants: uppercase with underscores.
- Keep comments concise and operational (what/why). German comments are acceptable but favor clear English going forward.
- Prefer long-form flags and explicit paths; avoid implicit `$PATH` reliance when running privileged commands.
## Testing Guidelines
- Add unit-like checks via small harness scripts or bats tests under `tests/` if complexity grows.
- Validate shutdown/notification branches using a non-privileged test user; avoid running destructive paths on production users.
- When adding flags or behaviors, document expected outcomes and add example invocations to this file.
## Commit & Pull Request Guidelines
- Use imperative, present-tense commit messages; include scope when helpful (e.g., `lint: address shellcheck warnings`).
- PRs should state behavior changes, risks, and manual test steps. If UI/notifications change, include example command lines and observed output.
- Link related tickets or incident IDs where applicable; keep changes small and reviewable.
## Security & Configuration Tips
- Script assumes root; never hardcode passwords or tokens. Validate `$USER_TO_MANAGE` exists before mutating accounts.
- Be cautious with shutdown logic: guard new features to avoid unintended reboots on inactive sessions.
- When adding sounds or notifications, prefer system-provided assets and handle missing dependencies gracefully as in `sk.sh`.

62
Makefile Normal file
View File

@ -0,0 +1,62 @@
SUDO ?= sudo
SERVICE ?= skd
SERVICE_USER ?= skd
SERVICE_GROUP ?= $(SERVICE_USER)
INSTALL_DIR ?= /opt/sk
ENV_DIR ?= /etc/$(SERVICE)
ENV_FILE ?= $(ENV_DIR)/env
SYSTEMD_PATH ?= /etc/systemd/system/$(SERVICE).service
BRANCH ?= main
HOST ?= 127.0.0.1
PORT ?= 8000
HEALTH_URL ?= http://$(HOST):$(PORT)/health
TOKEN ?= $(shell awk -F= '/^SKD_AUTH_TOKEN=/{print $$2}' $(ENV_FILE) 2>/dev/null)
KEEP_INSTALL_DIR ?= 1
.PHONY: install up down uninstall healthcheck update token
install:
$(SUDO) env SERVICE_NAME=$(SERVICE) SERVICE_USER=$(SERVICE_USER) SERVICE_GROUP=$(SERVICE_GROUP) INSTALL_DIR=$(INSTALL_DIR) ./scripts/install.sh
up:
$(SUDO) systemctl start $(SERVICE).service
down:
$(SUDO) systemctl stop $(SERVICE).service
uninstall:
-$(SUDO) systemctl stop $(SERVICE).service
-$(SUDO) systemctl disable $(SERVICE).service
$(SUDO) rm -f $(SYSTEMD_PATH)
$(SUDO) systemctl daemon-reload
@if [ "$(KEEP_INSTALL_DIR)" != "1" ]; then \
echo "Removing $(INSTALL_DIR)..."; \
$(SUDO) rm -rf "$(INSTALL_DIR)"; \
else \
echo "Keeping install dir $(INSTALL_DIR); set KEEP_INSTALL_DIR=0 to remove."; \
fi
healthcheck:
@if [ -z "$(TOKEN)" ]; then \
echo "No token set; set TOKEN=... or populate $(ENV_FILE) with SKD_AUTH_TOKEN."; \
exit 1; \
fi
curl -fsS -H "X-API-Token: $(TOKEN)" "$(HEALTH_URL)" || (echo "Health check failed" && exit 1)
update:
$(SUDO) env PROJECT_ROOT=$(INSTALL_DIR) SERVICE_NAME=$(SERVICE) SERVICE_USER=$(SERVICE_USER) BRANCH=$(BRANCH) bash -c 'cd $(INSTALL_DIR) && ./scripts/update.sh'
token:
@if [ -z "$(TOKEN)" ]; then \
TOKEN=$$(head -c 32 /dev/urandom | base64 | tr -d '\n'); \
else \
TOKEN="$(TOKEN)"; \
fi; \
$(SUDO) mkdir -p "$(ENV_DIR)"; \
tmp="$(ENV_FILE).tmp"; \
$(SUDO) sh -c 'grep -v "^SKD_AUTH_TOKEN=" "$(ENV_FILE)" 2>/dev/null > "$$tmp" || true'; \
$(SUDO) sh -c 'echo "SKD_AUTH_TOKEN='"'"'$$TOKEN'"'"'" >> "$$tmp"'; \
$(SUDO) mv "$$tmp" "$(ENV_FILE)"; \
$(SUDO) chown root:$(SERVICE_GROUP) "$(ENV_FILE)"; \
$(SUDO) chmod 640 "$(ENV_FILE)"; \
echo "Token written to $(ENV_FILE)"

75
README.md Normal file
View File

@ -0,0 +1,75 @@
# Safe Kiddo Daemon
Service to lock/unlock local user accounts on kids' laptops with countdown, notifications, optional sound, and shutdown. Provides a REST API plus a small web UI for remote control; retains the original `sk.sh` script as legacy/CLI fallback.
## Features
- Disable/enable accounts, terminate sessions, optionally trigger shutdown.
- Desktop notifications and optional sound during countdown.
- Login-protected API with minimal web UI (PAM auth for root users, bearer token for calls).
- Systemd-managed service, virtualenv-based deploy, remote update script.
- Dry-run mode to validate flows without touching accounts.
## Quick Start (Local/Target Device)
```bash
git clone <repo> /opt/sk
cd /opt/sk
./scripts/install.sh
sudo systemctl status skd.service
```
Then open `http://localhost:8000/` and set the API token in the UI.
## Configuration
Set in `/etc/skd/env` (see `env.example`):
- `SKD_AUTH_SECRET`: HMAC secret for bearer tokens (set a strong value).
- `SKD_TOKEN_TTL_SECONDS`: token lifetime (default 900s).
- `SKD_AUTH_ALLOWED_USERS`: optional comma list of accounts allowed to log in.
- `SKD_AUTH_ALLOWED_GROUPS`: groups whose members may log in (default `sudo`).
- `SKD_ALLOWED_USERS`: optional comma list to limit manageable accounts (must exist on the system).
- `SKD_DEFAULT_COUNTDOWN`, `SKD_DEFAULT_SOUND`, `SKD_NOTIFY_TIMEOUT`: behavior defaults.
- `SKD_DRY_RUN=true` to test without real account changes or shutdown.
- `SKD_SOUND_PLAYER`/`SKD_SOUND_FILE`, `SKD_NOTIFY_SEND_PATH` if defaults differ.
Notes:
- `./scripts/install.sh` will create `/etc/skd/env` from `env.example` if missing (edit afterwards) and ensure the `skd` service user/group exist.
## Running
- Service: managed by systemd; `./scripts/install.sh` writes the unit dynamically to `/etc/systemd/system/skd.service` with the current repo path and restarts it.
- Manual run: `./scripts/run.sh` (uses `.venv`, defaults to `0.0.0.0:8000`).
- Login: `curl -X POST -H "Content-Type: application/json" -d '{"username":"root","password":"..."}' http://localhost:8000/login`
- Health: `curl -H "Authorization: Bearer <token>" http://localhost:8000/health`
## API (Bearer token via `/login`)
- `GET /users` → `[{user, logged_in}]` (manageable system users; excludes root)
- `POST /users/{name}/disable` with JSON `{countdown?, sound?, message?}`
- `POST /users/{name}/enable`
- `GET /health`
Example:
```bash
token=$(curl -s -X POST -H "Content-Type: application/json" -d '{"username":"root","password":"..."}' http://localhost:8000/login | jq -r .token)
curl -X POST -H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d '{"countdown":90,"sound":true}' \
http://localhost:8000/users/child1/disable
```
## Web UI
Served at `/`. Login mit Root-Account, danach werden verfügbare System-User angezeigt; Aktionen senden Bearer Token automatisch.
## Updates
- Remote update via SSH: `ssh user@kid-laptop 'cd /opt/sk && ./scripts/update.sh'` (fetch/reset to `origin/main`, reinstalls deps, restarts service).
- Manual: `git pull && source .venv/bin/activate && pip install -r backend/requirements.txt && sudo systemctl restart skd`
## Deployment (zip/SSH)
- Quick copy: create `sk_deploy.zip` (already in repo root) and unzip on target under `/opt/sk`, then refresh venv deps and restart service.
- Scripted deploy: edit `deploy_hosts.yml` (host/user/port/install_dir/service user/group), then run `./scripts/deploy.sh <host-name>`; accepts JSON configs too. Requires SSH access and `sudo` on target.
- After deploy on target: `sudo -u skd /opt/sk/.venv/bin/pip install -r /opt/sk/backend/requirements.txt && sudo systemctl restart skd.service`
## Security Hardening
- Restrict access to API/Web UI to LAN/VPN; firewall the port.
- Set a strong `SKD_AUTH_SECRET`; rotate tokens by changing the secret.
- Create dedicated `skd` user/group; no login shell.
- Configure sudoers minimally: allow `skd` to run `usermod -L/-U`, `pkill -KILL -u`, `shutdown now`, and sound/notify binaries if needed (no full passwordless sudo).
- Consider mTLS or IP allowlisting for added protection.
## Legacy Script
`sk.sh` remains for direct SSH use. Plan to replace its logic with API-backed helpers; keep it as emergency fallback.

1
backend/__init__.py Normal file
View File

@ -0,0 +1 @@
# Makes backend a package.

165
backend/actions.py Normal file
View File

@ -0,0 +1,165 @@
import logging
import shutil
import subprocess
import time
from typing import List, Optional
from backend.settings import get_settings
logger = logging.getLogger(__name__)
class ActionError(Exception):
pass
def _run(cmd: List[str], env: Optional[dict] = None) -> subprocess.CompletedProcess:
"""Run a command respecting dry-run mode."""
settings = get_settings()
if settings.dry_run:
logger.info("[dry-run] %s", " ".join(cmd))
return subprocess.CompletedProcess(cmd, 0, stdout="[dry-run]", stderr="")
return subprocess.run(cmd, check=True, capture_output=True, text=True, env=env)
def _user_exists(user: str) -> bool:
try:
return _run(["id", user]).returncode == 0
except subprocess.CalledProcessError:
return False
def _get_uid(user: str) -> Optional[str]:
try:
result = _run(["id", "-u", user])
return result.stdout.strip()
except subprocess.CalledProcessError:
return None
def _is_logged_in(user: str) -> bool:
try:
result = _run(["who"])
for line in result.stdout.splitlines():
if line.strip() == "[dry-run]":
continue
if line.startswith(f"{user} "):
return True
except subprocess.CalledProcessError:
logger.warning("Could not determine login status for %s", user)
return False
def _play_sound_if_available() -> Optional[str]:
settings = get_settings()
if not settings.sound_player:
return "sound player not configured"
if not shutil.which(settings.sound_player):
return f"{settings.sound_player} not found"
try:
_run([settings.sound_player, settings.sound_file])
except subprocess.CalledProcessError as exc:
logger.warning("Sound failed: %s", exc)
return "sound failed"
return None
def _notify_user(user: str, message: str) -> Optional[str]:
settings = get_settings()
notify = shutil.which(settings.notify_send_path)
if not notify:
return "notify-send not available"
uid = _get_uid(user)
if not uid:
return "could not resolve uid for notification"
env = {
"DISPLAY": ":0",
"DBUS_SESSION_BUS_ADDRESS": f"unix:path=/run/user/{uid}/bus",
}
cmd = [
"sudo",
"-u",
user,
notify,
"Safe Kiddo",
message,
"-t",
str(settings.notify_timeout * 1000),
]
try:
_run(cmd, env=env)
except subprocess.CalledProcessError as exc:
logger.warning("Notification failed: %s", exc)
return "notification failed"
return None
def disable_user(
user: str,
countdown: Optional[int] = None,
sound: Optional[bool] = None,
message: Optional[str] = None,
) -> List[str]:
settings = get_settings()
if not _user_exists(user):
raise ActionError(f"user '{user}' not found")
countdown_seconds = settings.default_countdown if countdown is None else countdown
play_sound = settings.default_sound if sound is None else sound
steps: List[str] = []
_run(["sudo", "usermod", "-L", user])
steps.append("account locked")
logged_in = _is_logged_in(user)
if logged_in:
steps.append("user is logged in")
info_message = message or f"Shutdown in {countdown_seconds} seconds. Save your work."
notify_result = _notify_user(user, info_message)
if notify_result:
steps.append(notify_result)
if play_sound:
sound_result = _play_sound_if_available()
if sound_result:
steps.append(sound_result)
if countdown_seconds > 0 and not settings.dry_run:
for remaining in range(countdown_seconds, 0, -1):
time.sleep(1)
if remaining % 10 == 0 or remaining <= 5:
_notify_user(user, f"Shutdown in {remaining} seconds.")
if play_sound:
_play_sound_if_available()
_run(["sudo", "pkill", "-KILL", "-u", user])
steps.append("sessions terminated")
if logged_in:
_run(["sudo", "shutdown", "now"])
steps.append("system shutdown triggered")
else:
steps.append("no active session; shutdown skipped")
return steps
def enable_user(user: str) -> List[str]:
if not _user_exists(user):
raise ActionError(f"user '{user}' not found")
_run(["sudo", "usermod", "-U", user])
return ["account unlocked"]
def list_logged_in_users() -> List[str]:
try:
result = _run(["who"])
users: List[str] = []
for line in result.stdout.splitlines():
if line.strip() in ("", "[dry-run]"):
continue
users.append(line.split()[0])
return users
except subprocess.CalledProcessError:
return []

112
backend/app.py Normal file
View File

@ -0,0 +1,112 @@
import logging
from typing import List
from fastapi import Body, Depends, FastAPI, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from backend import actions
from backend.actions import ActionError
from backend.auth import authenticate_admin_user, get_current_admin, issue_token, list_manageable_users
from backend.models import ActionRequest, ActionResponse, LoginRequest, LoginResponse, UserStatus
from backend.settings import Settings, get_settings
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s - %(message)s",
)
logger = logging.getLogger("skd")
app = FastAPI(title="Safe Kiddo Daemon", version="1.0.0")
templates = Jinja2Templates(directory="backend/templates")
def validate_user(username: str, settings: Settings = Depends(get_settings)) -> str:
allowed = set(list_manageable_users(settings))
if username not in allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not allowed")
return username
@app.get("/health")
def health(settings: Settings = Depends(get_settings)) -> dict:
return {"status": "ok", "dry_run": settings.dry_run}
@app.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, settings: Settings = Depends(get_settings)) -> LoginResponse:
authenticate_admin_user(payload.username, payload.password, settings)
token = issue_token(payload.username, settings)
return LoginResponse(token=token, expires_in=settings.token_ttl_seconds)
@app.get("/users", response_model=List[UserStatus], dependencies=[Depends(get_current_admin)])
def users(settings: Settings = Depends(get_settings)) -> List[UserStatus]:
logged_in = set(actions.list_logged_in_users())
targets = list_manageable_users(settings)
return [UserStatus(user=user, logged_in=user in logged_in) for user in targets]
@app.post(
"/users/{username}/disable",
response_model=ActionResponse,
dependencies=[Depends(get_current_admin)],
)
def disable_user(
username: str = Depends(validate_user),
payload: ActionRequest | None = Body(default=None),
settings: Settings = Depends(get_settings),
) -> ActionResponse:
try:
steps = actions.disable_user(
username,
countdown=payload.countdown if payload else None,
sound=payload.sound if payload else None,
message=payload.message if payload else None,
)
except ActionError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except Exception as exc: # pragma: no cover - safeguard
logger.exception("Failed to disable %s", username)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed") from exc
logged_in = username in actions.list_logged_in_users()
return ActionResponse(
user=username,
action="disable",
dry_run=settings.dry_run,
steps=steps,
logged_in=logged_in,
)
@app.post(
"/users/{username}/enable",
response_model=ActionResponse,
dependencies=[Depends(get_current_admin)],
)
def enable_user(
username: str = Depends(validate_user),
settings: Settings = Depends(get_settings),
) -> ActionResponse:
try:
steps = actions.enable_user(username)
except ActionError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except Exception as exc: # pragma: no cover - safeguard
logger.exception("Failed to enable %s", username)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed") from exc
logged_in = username in actions.list_logged_in_users()
return ActionResponse(
user=username,
action="enable",
dry_run=settings.dry_run,
steps=steps,
logged_in=logged_in,
)
@app.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse("index.html", {"request": request})

107
backend/auth.py Normal file
View File

@ -0,0 +1,107 @@
import datetime as dt
import grp
import pwd
from typing import List, Set
import jwt
import pam
from fastapi import Depends, HTTPException, Request, status
from backend.settings import Settings, get_settings
def _is_member_of(username: str, groups: Set[str]) -> bool:
if not groups:
return False
try:
user_entry = pwd.getpwnam(username)
except KeyError:
return False
user_groups = {g.gr_name for g in grp.getgrall() if username in g.gr_mem}
primary_group = grp.getgrgid(user_entry.pw_gid).gr_name
user_groups.add(primary_group)
return bool(user_groups & groups)
def is_authorized_admin(username: str, settings: Settings) -> bool:
# UID 0 always allowed
try:
entry = pwd.getpwnam(username)
except KeyError:
return False
if entry.pw_uid == 0:
return True
allowed_users = set(settings.auth_allowed_users)
allowed_groups = set(settings.auth_allowed_groups)
if username in allowed_users:
return True
if _is_member_of(username, allowed_groups):
return True
return False
def authenticate_admin_user(username: str, password: str, settings: Settings) -> None:
if not is_authorized_admin(username, settings):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User not authorized to log in",
)
pam_client = pam.pam()
if not pam_client.authenticate(username, password, service=settings.auth_pam_service):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
)
def issue_token(username: str, settings: Settings) -> str:
payload = {
"sub": username,
"exp": dt.datetime.utcnow() + dt.timedelta(seconds=settings.token_ttl_seconds),
"iat": dt.datetime.utcnow(),
}
return jwt.encode(payload, settings.auth_secret, algorithm="HS256")
def decode_token(token: str, settings: Settings) -> str:
try:
payload = jwt.decode(token, settings.auth_secret, algorithms=["HS256"])
except jwt.ExpiredSignatureError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") from exc
except jwt.PyJWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
username = payload.get("sub")
if not username or not is_authorized_admin(username, settings):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Unauthorized user")
return username
def get_current_admin(
request: Request,
settings: Settings = Depends(get_settings),
) -> str:
auth_header = request.headers.get("authorization")
if not auth_header or not auth_header.lower().startswith("bearer "):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
token = auth_header.split(" ", 1)[1].strip()
return decode_token(token, settings)
def list_manageable_users(settings: Settings) -> List[str]:
"""List system users we are allowed to manage (uid >= 1000, real shells, optional allowlist)."""
allowed = set(settings.allowed_users) if settings.allowed_users else None
candidates: List[str] = []
for entry in pwd.getpwall():
if entry.pw_uid == 0:
# Never operate on root accounts
continue
if entry.pw_uid < 1000:
continue
if entry.pw_shell in ("/usr/sbin/nologin", "/bin/false"):
continue
if allowed is not None and entry.pw_name not in allowed:
continue
candidates.append(entry.pw_name)
candidates.sort()
return candidates

35
backend/models.py Normal file
View File

@ -0,0 +1,35 @@
from typing import List, Optional
from pydantic import BaseModel, Field
class ActionRequest(BaseModel):
countdown: Optional[int] = Field(default=None, ge=0, description="Seconds for countdown")
sound: Optional[bool] = Field(default=None, description="Play sound alongside notification")
message: Optional[str] = Field(
default=None,
description="Optional message to show the user; falls back to default text.",
)
class ActionResponse(BaseModel):
user: str
action: str
dry_run: bool
steps: List[str]
logged_in: bool
class UserStatus(BaseModel):
user: str
logged_in: bool
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
token: str
expires_in: int

7
backend/requirements.txt Normal file
View File

@ -0,0 +1,7 @@
fastapi==0.111.0
uvicorn[standard]==0.30.1
pydantic==2.7.3
jinja2==3.1.4
PyJWT==2.9.0
python-pam==2.0.2
six==1.16.0

37
backend/settings.py Normal file
View File

@ -0,0 +1,37 @@
import os
from functools import lru_cache
from typing import List
class Settings:
"""Application settings loaded from environment."""
def __init__(self) -> None:
self.allowed_users: List[str] = self._parse_list(os.getenv("SKD_ALLOWED_USERS", ""))
self.auth_secret: str = os.getenv("SKD_AUTH_SECRET", "change-me-secret")
self.token_ttl_seconds: int = int(os.getenv("SKD_TOKEN_TTL_SECONDS", "900"))
self.auth_allowed_users: List[str] = self._parse_list(os.getenv("SKD_AUTH_ALLOWED_USERS", ""))
self.auth_allowed_groups: List[str] = self._parse_list(
os.getenv("SKD_AUTH_ALLOWED_GROUPS", "sudo")
)
self.auth_pam_service: str = os.getenv("SKD_AUTH_PAM_SERVICE", "login")
self.default_countdown: int = int(os.getenv("SKD_DEFAULT_COUNTDOWN", "60"))
self.default_sound: bool = os.getenv("SKD_DEFAULT_SOUND", "false").lower() == "true"
self.notify_timeout: int = int(os.getenv("SKD_NOTIFY_TIMEOUT", "5"))
self.dry_run: bool = os.getenv("SKD_DRY_RUN", "false").lower() == "true"
# Paths/tools
self.notify_send_path: str = os.getenv("SKD_NOTIFY_SEND_PATH", "notify-send")
self.sound_player: str = os.getenv("SKD_SOUND_PLAYER", "paplay")
self.sound_file: str = os.getenv(
"SKD_SOUND_FILE",
"/usr/share/sounds/freedesktop/stereo/dialog-warning.oga",
)
@staticmethod
def _parse_list(value: str) -> List[str]:
return [item for item in (part.strip() for part in value.split(",")) if item]
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()

View File

@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Safe Kiddo Control</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" />
<style>
body { max-width: 960px; margin: auto; padding: 1.5rem; }
.log { white-space: pre-line; }
form { margin-bottom: 1rem; }
</style>
</head>
<body>
<header>
<h1>Safe Kiddo Control</h1>
<p>Steuere Nutzerkonten über die lokale API. Stelle sicher, dass der API-Token gesetzt ist.</p>
</header>
<section>
<h3>Login (nur Root-User)</h3>
<form id="loginForm">
<div class="grid">
<div>
<label for="loginUser">Benutzer</label>
<input id="loginUser" name="loginUser" autocomplete="username" required />
</div>
<div>
<label for="loginPass">Passwort</label>
<input id="loginPass" name="loginPass" type="password" autocomplete="current-password" required />
</div>
</div>
<button type="submit">Anmelden</button>
</form>
<div id="loginStatus" class="log"></div>
</section>
<section>
<h3>Status abrufen</h3>
<button id="refreshBtn">Status laden</button>
<div id="status" class="log"></div>
</section>
<section>
<h3>Aktion ausführen</h3>
<form id="actionForm">
<div class="grid">
<div>
<label for="username">Benutzer</label>
<select id="username" name="username" required>
<option value="">-- wählen --</option>
</select>
</div>
<div>
<label for="action">Aktion</label>
<select id="action" name="action">
<option value="disable">Disable</option>
<option value="enable">Enable</option>
</select>
</div>
</div>
<div class="grid">
<div>
<label for="countdown">Countdown (Sekunden, optional)</label>
<input id="countdown" name="countdown" type="number" min="0" />
</div>
<div>
<label for="sound">Sound</label>
<select id="sound" name="sound">
<option value="">Default</option>
<option value="true">An</option>
<option value="false">Aus</option>
</select>
</div>
</div>
<label for="message">Nachricht (optional)</label>
<input id="message" name="message" />
<button type="submit">Senden</button>
</form>
<div id="result" class="log"></div>
</section>
<script>
const statusDiv = document.getElementById('status');
const resultDiv = document.getElementById('result');
const loginStatus = document.getElementById('loginStatus');
const tokenKey = 'skdToken';
let currentToken = sessionStorage.getItem(tokenKey) || '';
function setToken(token) {
currentToken = token;
if (token) {
sessionStorage.setItem(tokenKey, token);
loginStatus.textContent = 'Angemeldet';
} else {
sessionStorage.removeItem(tokenKey);
loginStatus.textContent = 'Nicht angemeldet';
}
}
setToken(currentToken);
function authHeaders() {
const headers = { 'Content-Type': 'application/json' };
if (currentToken) headers['Authorization'] = `Bearer ${currentToken}`;
return headers;
}
async function api(path, options = {}) {
const headers = { ...authHeaders(), ...(options.headers || {}) };
const res = await fetch(path, { ...options, headers });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
async function refreshUsers() {
statusDiv.textContent = 'Lade...';
try {
const data = await api('/users');
statusDiv.textContent = data.map(u => `${u.user}: ${u.logged_in ? 'eingeloggt' : 'aus'}`).join('\n') || 'Keine Daten';
const select = document.getElementById('username');
select.innerHTML = '<option value="">-- wählen --</option>';
data.forEach(u => {
const opt = document.createElement('option');
opt.value = u.user;
opt.textContent = u.user;
select.appendChild(opt);
});
} catch (err) {
statusDiv.textContent = `Fehler: ${err.message}`;
}
}
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
loginStatus.textContent = 'Anmeldung...';
const username = document.getElementById('loginUser').value.trim();
const password = document.getElementById('loginPass').value;
try {
const data = await api('/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
setToken(data.token);
loginStatus.textContent = 'Anmeldung erfolgreich';
await refreshUsers();
} catch (err) {
setToken('');
loginStatus.textContent = `Login fehlgeschlagen: ${err.message}`;
}
});
document.getElementById('refreshBtn').addEventListener('click', refreshUsers);
document.getElementById('actionForm').addEventListener('submit', async (e) => {
e.preventDefault();
resultDiv.textContent = 'Sende...';
const username = document.getElementById('username').value;
const action = document.getElementById('action').value;
const countdown = document.getElementById('countdown').value;
const sound = document.getElementById('sound').value;
const message = document.getElementById('message').value.trim();
const body = {};
if (countdown) body.countdown = Number(countdown);
if (sound === 'true') body.sound = true;
if (sound === 'false') body.sound = false;
if (message) body.message = message;
try {
const data = await api(`/users/${encodeURIComponent(username)}/${action}`, {
method: 'POST',
body: Object.keys(body).length ? JSON.stringify(body) : '{}'
});
resultDiv.textContent = `${data.action} ${data.user}: ${data.steps.join('; ')}`;
} catch (err) {
resultDiv.textContent = `Fehler: ${err.message}`;
}
});
</script>
</body>
</html>

9
deploy_hosts.yml Normal file
View File

@ -0,0 +1,9 @@
hosts:
- name: kid-laptop
host: 192.168.13.14
user: stephan
port: 22
install_dir: /opt/sk
service_name: skd
service_user: skd
service_group: skd

13
env.example Normal file
View File

@ -0,0 +1,13 @@
# Copy to /etc/skd/env or .env for local runs
# Optional allowlist of manageable users (otherwise all real users with uid>=1000)
SKD_ALLOWED_USERS=child1,child2
SKD_AUTH_SECRET=change-me-secret
SKD_TOKEN_TTL_SECONDS=900
SKD_AUTH_ALLOWED_USERS=
SKD_AUTH_ALLOWED_GROUPS=sudo
SKD_AUTH_PAM_SERVICE=login
SKD_DEFAULT_COUNTDOWN=60
SKD_DEFAULT_SOUND=false
SKD_NOTIFY_TIMEOUT=5
# Set to true to test without performing real system changes
SKD_DRY_RUN=false

21
scripts/create_venv.sh Executable file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
PROJECT_ROOT="${PROJECT_ROOT:-${DEFAULT_ROOT}}"
PYTHON_BIN="${PYTHON_BIN:-python3}"
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
echo "Python executable not found: ${PYTHON_BIN}" >&2
exit 1
fi
cd "${PROJECT_ROOT}"
"${PYTHON_BIN}" -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r backend/requirements.txt
echo "Virtualenv ready at ${PROJECT_ROOT}/.venv"

143
scripts/deploy.sh Normal file
View File

@ -0,0 +1,143 @@
#!/usr/bin/env bash
set -euo pipefail
# Deploys the current working tree to a remote host using a small config file (YAML or JSON).
# Usage: scripts/deploy.sh <host-name> [config-file]
# Config default: deploy_hosts.yml with structure:
# hosts:
# - name: kid-laptop
# host: 192.168.13.14
# user: stephan
# port: 22
# install_dir: /opt/sk
# service_name: skd
# service_user: skd
# service_group: skd
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <host-name> [config-file]" >&2
exit 1
fi
HOST_NAME="$1"
CONFIG_FILE="${2:-deploy_hosts.yml}"
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ ! -f "${CONFIG_FILE}" ]]; then
echo "Config file not found: ${CONFIG_FILE}" >&2
exit 1
fi
read_config() {
python3 - <<'PY' "$HOST_NAME" "$CONFIG_FILE"
import importlib
import json
import pathlib
import shlex
import sys
host_name = sys.argv[1]
config_path = pathlib.Path(sys.argv[2])
text = config_path.read_text()
data = None
try:
data = json.loads(text)
except json.JSONDecodeError:
try:
yaml = importlib.import_module("yaml")
except ImportError:
sys.exit("Config is not JSON; install PyYAML (pip install pyyaml) or provide JSON.")
data = yaml.safe_load(text)
hosts = data.get("hosts") if isinstance(data, dict) else None
if not hosts:
sys.exit("Config missing 'hosts' list")
match = None
for entry in hosts:
if entry.get("name") == host_name:
match = entry
break
if not match:
sys.exit(f"No host named '{host_name}' in config")
def req(key, default=None):
val = match.get(key, default)
if val is None:
sys.exit(f"Missing required key '{key}' for host '{host_name}'")
return val
host = req("host")
user = req("user")
port = int(match.get("port", 22))
install_dir = req("install_dir", "/opt/sk")
service_name = match.get("service_name", "skd")
service_user = match.get("service_user", "skd")
service_group = match.get("service_group", service_user)
for key, val in {
"REMOTE_HOST": host,
"REMOTE_USER": user,
"REMOTE_PORT": str(port),
"INSTALL_DIR": install_dir,
"SERVICE_NAME": service_name,
"SERVICE_USER": service_user,
"SERVICE_GROUP": service_group,
}.items():
print(f'{key}={shlex.quote(val)}')
PY
}
eval "$(HOST_NAME="${HOST_NAME}" CONFIG_FILE="${CONFIG_FILE}" read_config)"
ARCHIVE="$(mktemp /tmp/skpkg.XXXXXX.tar.gz)"
trap 'rm -f "${ARCHIVE}"' EXIT
echo "[*] Packaging working tree (excluding .venv/.git)..."
tar czf "${ARCHIVE}" \
--exclude '.venv' \
--exclude '.git' \
--exclude 'sk_deploy.zip' \
-C "${PROJECT_ROOT}" .
REMOTE_PACKAGE="/tmp/skpkg.tar.gz"
echo "[*] Copying package to ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PACKAGE} ..."
scp -P "${REMOTE_PORT}" "${ARCHIVE}" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PACKAGE}"
read -r -d '' REMOTE_CMD <<'RCMD'
set -euo pipefail
INSTALL_DIR="${INSTALL_DIR}"
SERVICE_NAME="${SERVICE_NAME}"
SERVICE_USER="${SERVICE_USER}"
SERVICE_GROUP="${SERVICE_GROUP}"
PKG="${REMOTE_PACKAGE}"
sudo mkdir -p "${INSTALL_DIR}"
sudo rm -rf "${INSTALL_DIR:?}"/*
sudo tar xzf "${PKG}" -C "${INSTALL_DIR}"
sudo chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "${INSTALL_DIR}"
if ! sudo -u "${SERVICE_USER}" test -x "${INSTALL_DIR}/.venv/bin/python3"; then
sudo -u "${SERVICE_USER}" python3 -m venv "${INSTALL_DIR}/.venv"
fi
sudo -u "${SERVICE_USER}" "${INSTALL_DIR}/.venv/bin/pip" install --upgrade pip
sudo -u "${SERVICE_USER}" "${INSTALL_DIR}/.venv/bin/pip" install -r "${INSTALL_DIR}/backend/requirements.txt"
sudo systemctl daemon-reload
sudo systemctl restart "${SERVICE_NAME}.service"
RCMD
echo "[*] Deploying on remote host..."
ssh -p "${REMOTE_PORT}" "${REMOTE_USER}@${REMOTE_HOST}" \
INSTALL_DIR="${INSTALL_DIR}" \
SERVICE_NAME="${SERVICE_NAME}" \
SERVICE_USER="${SERVICE_USER}" \
SERVICE_GROUP="${SERVICE_GROUP}" \
REMOTE_PACKAGE="${REMOTE_PACKAGE}" \
bash -s <<<"${REMOTE_CMD}"
echo "[*] Deployment finished. Check status on remote: sudo systemctl status ${SERVICE_NAME}.service"

84
scripts/install.sh Executable file
View File

@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
SERVICE_NAME="${SERVICE_NAME:-skd}"
SERVICE_USER="${SERVICE_USER:-skd}"
SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}"
INSTALL_DIR="${INSTALL_DIR:-/opt/sk}"
ENV_DIR="/etc/${SERVICE_NAME}"
ENV_FILE="${ENV_DIR}/env"
SYSTEMD_PATH="/etc/systemd/system/${SERVICE_NAME}.service"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Required command not found: $1" >&2
exit 1
fi
}
log "Ensuring service user/group (${SERVICE_USER}) exist..."
if ! getent group "${SERVICE_GROUP}" >/dev/null 2>&1; then
sudo groupadd -r "${SERVICE_GROUP}"
fi
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
sudo useradd -r -s /usr/sbin/nologin -d "${INSTALL_DIR}" -g "${SERVICE_GROUP}" "${SERVICE_USER}"
log "Created system user ${SERVICE_USER}"
fi
require_cmd rsync
log "Syncing project to ${INSTALL_DIR}..."
sudo mkdir -p "${INSTALL_DIR}"
sudo rsync -a --delete --exclude '.venv' "${SOURCE_DIR}/" "${INSTALL_DIR}/"
sudo chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "${INSTALL_DIR}"
log "Creating/refreshing virtualenv in ${INSTALL_DIR}..."
sudo -u "${SERVICE_USER}" PROJECT_ROOT="${INSTALL_DIR}" "${SOURCE_DIR}/scripts/create_venv.sh"
log "Preparing environment file..."
sudo mkdir -p "${ENV_DIR}"
if [[ ! -f "${ENV_FILE}" ]]; then
if [[ -f "${SOURCE_DIR}/env.example" ]]; then
sudo cp "${SOURCE_DIR}/env.example" "${ENV_FILE}"
log "Copied env.example to ${ENV_FILE}; edit SKD_AUTH_TOKEN and SKD_ALLOWED_USERS."
else
sudo touch "${ENV_FILE}"
log "Created empty ${ENV_FILE}; populate required variables."
fi
sudo chmod 640 "${ENV_FILE}"
sudo chown root:"${SERVICE_GROUP}" "${ENV_FILE}"
fi
log "Writing systemd unit to ${SYSTEMD_PATH}..."
sudo tee "${SYSTEMD_PATH}" >/dev/null <<EOF
[Unit]
Description=Safe Kiddo Daemon
After=network.target
[Service]
Type=simple
User=${SERVICE_USER}
Group=${SERVICE_GROUP}
WorkingDirectory=${INSTALL_DIR}
EnvironmentFile=${ENV_FILE}
ExecStart=${INSTALL_DIR}/.venv/bin/uvicorn backend.app:app --host 0.0.0.0 --port 8000
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
log "Enabling and starting service..."
sudo systemctl daemon-reload
sudo systemctl enable "${SERVICE_NAME}.service"
sudo systemctl restart "${SERVICE_NAME}.service"
log "Installation finished. Check status: sudo systemctl status ${SERVICE_NAME}.service"

14
scripts/run.sh Executable file
View File

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${PROJECT_ROOT}"
source .venv/bin/activate
UVICORN_CMD=${UVICORN_CMD:-uvicorn}
HOST=${HOST:-0.0.0.0}
PORT=${PORT:-8000}
exec "${UVICORN_CMD}" backend.app:app --host "${HOST}" --port "${PORT}"

23
scripts/update.sh Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
PROJECT_ROOT="${PROJECT_ROOT:-${DEFAULT_ROOT}}"
SERVICE_NAME="${SERVICE_NAME:-skd}"
SERVICE_USER="${SERVICE_USER:-skd}"
BRANCH="${BRANCH:-main}"
cd "${PROJECT_ROOT}"
echo "[*] Fetching updates..."
git fetch --all
git reset --hard "origin/${BRANCH}"
echo "[*] Refreshing dependencies..."
sudo -u "${SERVICE_USER}" PROJECT_ROOT="${PROJECT_ROOT}" "${SCRIPT_DIR}/create_venv.sh"
echo "[*] Restarting service..."
sudo systemctl restart "${SERVICE_NAME}.service"
echo "[*] Update finished."

120
sk.sh Executable file
View File

@ -0,0 +1,120 @@
#!/bin/bash
# safe_kiddo_user_control.sh
# Deaktiviert/Aktiviert Userkonten mit optionalem Countdown, Sound und Shutdown nur bei aktivem Login
# Ausführen mit root-Rechten!
USER_TO_MANAGE="$1"
ACTION="$2"
COUNTDOWN_MODE="$3" # optional: "countdown"
SOUND_MODE="$4" # optional: "sound"
COUNTDOWN_TIME="$5" # optional: Sekundenanzahl (default 60)
if [[ -z "$COUNTDOWN_TIME" ]]; then
COUNTDOWN_TIME=60
fi
if [[ -z "$USER_TO_MANAGE" || -z "$ACTION" ]]; then
echo "Nutzung: $0 <username> <disable|enable> [countdown] [sound] [countdown_time_in_seconds]"
exit 1
fi
# Prüfe, ob notify-send verfügbar ist
if ! command -v notify-send &> /dev/null; then
echo "[WARNUNG] notify-send nicht gefunden. Bitte installiere libnotify-bin."
NOTIFY_AVAILABLE=false
else
NOTIFY_AVAILABLE=true
fi
# Prüfe, ob ein Sound-Tool verfügbar ist
if command -v paplay &> /dev/null; then
SOUND_PLAYER="paplay"
SOUND_FILE="/usr/share/sounds/freedesktop/stereo/dialog-warning.oga"
elif command -v aplay &> /dev/null; then
SOUND_PLAYER="aplay"
SOUND_FILE="/usr/share/sounds/alsa/Front_Center.wav"
elif command -v canberra-gtk-play &> /dev/null; then
SOUND_PLAYER="canberra-gtk-play"
SOUND_FILE="dialog-warning"
else
SOUND_PLAYER=""
fi
function send_notify() {
local message="$1"
if [[ "$NOTIFY_AVAILABLE" == true ]]; then
sudo -u "$USER_TO_MANAGE" DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u $USER_TO_MANAGE)/bus notify-send "Safe Kiddo" "$message"
else
echo "[INFO] Benachrichtigung übersprungen: $message"
fi
}
function is_user_logged_in() {
who | grep -q "^$USER_TO_MANAGE "
}
function play_beep() {
if [[ "$SOUND_MODE" == "sound" ]]; then
if [[ "$SOUND_PLAYER" == "paplay" ]]; then
paplay "$SOUND_FILE" &
elif [[ "$SOUND_PLAYER" == "aplay" ]]; then
aplay "$SOUND_FILE" &
elif [[ "$SOUND_PLAYER" == "canberra-gtk-play" ]]; then
canberra-gtk-play -i "$SOUND_FILE" -d "SafeKiddo" &
else
echo -ne '\007'
fi
fi
}
case "$ACTION" in
disable)
echo "[*] Deaktiviere Benutzer $USER_TO_MANAGE..."
sudo usermod -L "$USER_TO_MANAGE"
USER_WAS_LOGGED_IN=false
if is_user_logged_in; then
USER_WAS_LOGGED_IN=true
echo "[*] Benutzer ist eingeloggt."
if [[ "$COUNTDOWN_MODE" == "countdown" ]]; then
echo "[*] Starte dramatischen Countdown über $COUNTDOWN_TIME Sekunden..."
for ((i=COUNTDOWN_TIME; i>0; i--)); do
send_notify "ACHTUNG! Shutdown in $i Sekunden!"
play_beep
sleep 1
done
else
echo "[*] Schicke Warnung (ohne Countdown)..."
send_notify "Shutdown in $COUNTDOWN_TIME Sekunden! Speicher dein Spiel!"
echo "[*] Warten für $COUNTDOWN_TIME Sekunden..."
sleep "$COUNTDOWN_TIME"
fi
else
echo "[*] Benutzer ist NICHT eingeloggt. Countdown und Shutdown werden übersprungen."
fi
echo "[*] Benutzer abmelden (falls noch eingeloggt)..."
sudo pkill -KILL -u "$USER_TO_MANAGE" || true
if [[ "$USER_WAS_LOGGED_IN" == true ]]; then
echo "[*] Rechner wird jetzt heruntergefahren..."
sudo shutdown now
else
echo "[*] Kein aktiver Login – kein Shutdown."
fi
;;
enable)
echo "[*] Aktiviere Benutzer $USER_TO_MANAGE..."
sudo usermod -U "$USER_TO_MANAGE"
echo "[*] Benutzer $USER_TO_MANAGE kann sich wieder einloggen."
;;
*)
echo "Ungültige Aktion: $ACTION"
echo "Erlaubt sind: disable oder enable"
exit 1
;;
esac

BIN
sk_deploy.zip Normal file

Binary file not shown.

16
systemd/skd.service Normal file
View File

@ -0,0 +1,16 @@
[Unit]
Description=Safe Kiddo Daemon
After=network.target
[Service]
Type=simple
User=skd
Group=skd
WorkingDirectory=/opt/sk
EnvironmentFile=/etc/skd/env
ExecStart=/opt/sk/.venv/bin/uvicorn backend.app:app --host 0.0.0.0 --port 8000
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target