From 6f49528bbd8e84ebda523a77a97807bc174ad390 Mon Sep 17 00:00:00 2001 From: konoichi Date: Mon, 15 Dec 2025 12:07:32 +0100 Subject: [PATCH] first commit --- AGENTS.md | 33 +++++++ Makefile | 62 ++++++++++++ README.md | 75 +++++++++++++++ backend/__init__.py | 1 + backend/actions.py | 165 +++++++++++++++++++++++++++++++ backend/app.py | 112 ++++++++++++++++++++++ backend/auth.py | 107 +++++++++++++++++++++ backend/models.py | 35 +++++++ backend/requirements.txt | 7 ++ backend/settings.py | 37 +++++++ backend/templates/index.html | 181 +++++++++++++++++++++++++++++++++++ deploy_hosts.yml | 9 ++ env.example | 13 +++ scripts/create_venv.sh | 21 ++++ scripts/deploy.sh | 143 +++++++++++++++++++++++++++ scripts/install.sh | 84 ++++++++++++++++ scripts/run.sh | 14 +++ scripts/update.sh | 23 +++++ sk.sh | 120 +++++++++++++++++++++++ sk_deploy.zip | Bin 0 -> 19549 bytes systemd/skd.service | 16 ++++ 21 files changed, 1258 insertions(+) create mode 100644 AGENTS.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/__init__.py create mode 100644 backend/actions.py create mode 100644 backend/app.py create mode 100644 backend/auth.py create mode 100644 backend/models.py create mode 100644 backend/requirements.txt create mode 100644 backend/settings.py create mode 100644 backend/templates/index.html create mode 100644 deploy_hosts.yml create mode 100644 env.example create mode 100755 scripts/create_venv.sh create mode 100644 scripts/deploy.sh create mode 100755 scripts/install.sh create mode 100755 scripts/run.sh create mode 100755 scripts/update.sh create mode 100755 sk.sh create mode 100644 sk_deploy.zip create mode 100644 systemd/skd.service diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fc6f9d3 --- /dev/null +++ b/AGENTS.md @@ -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 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`. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..05b4a2c --- /dev/null +++ b/Makefile @@ -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)" diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e72093 --- /dev/null +++ b/README.md @@ -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 /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 " 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 `; 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. diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..048223d --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +# Makes backend a package. diff --git a/backend/actions.py b/backend/actions.py new file mode 100644 index 0000000..04152e4 --- /dev/null +++ b/backend/actions.py @@ -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 [] diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..3b3cd80 --- /dev/null +++ b/backend/app.py @@ -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}) diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..d766639 --- /dev/null +++ b/backend/auth.py @@ -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 diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..4cbea83 --- /dev/null +++ b/backend/models.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..ed84f22 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/settings.py b/backend/settings.py new file mode 100644 index 0000000..4a11b11 --- /dev/null +++ b/backend/settings.py @@ -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() diff --git a/backend/templates/index.html b/backend/templates/index.html new file mode 100644 index 0000000..0ff9766 --- /dev/null +++ b/backend/templates/index.html @@ -0,0 +1,181 @@ + + + + + + Safe Kiddo Control + + + + +
+

Safe Kiddo Control

+

Steuere Nutzerkonten über die lokale API. Stelle sicher, dass der API-Token gesetzt ist.

+
+ +
+

Login (nur Root-User)

+
+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

Status abrufen

+ +
+
+ +
+

Aktion ausführen

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + + +
+
+
+ + + + diff --git a/deploy_hosts.yml b/deploy_hosts.yml new file mode 100644 index 0000000..77db294 --- /dev/null +++ b/deploy_hosts.yml @@ -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 diff --git a/env.example b/env.example new file mode 100644 index 0000000..7d55c43 --- /dev/null +++ b/env.example @@ -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 diff --git a/scripts/create_venv.sh b/scripts/create_venv.sh new file mode 100755 index 0000000..c70e0ce --- /dev/null +++ b/scripts/create_venv.sh @@ -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" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..fd703c2 --- /dev/null +++ b/scripts/deploy.sh @@ -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 [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 [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" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..1936ce8 --- /dev/null +++ b/scripts/install.sh @@ -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 < [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 diff --git a/sk_deploy.zip b/sk_deploy.zip new file mode 100644 index 0000000000000000000000000000000000000000..5e12b456ce5300112047b3e3bb7d4d1ae0899605 GIT binary patch literal 19549 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 literal 0 HcmV?d00001 diff --git a/systemd/skd.service b/systemd/skd.service new file mode 100644 index 0000000..628c74c --- /dev/null +++ b/systemd/skd.service @@ -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