commit 6f49528bbd8e84ebda523a77a97807bc174ad390 Author: konoichi Date: Mon Dec 15 12:07:32 2025 +0100 first commit 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 0000000..5e12b45 Binary files /dev/null and b/sk_deploy.zip differ 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