first commit
This commit is contained in:
33
AGENTS.md
Normal file
33
AGENTS.md
Normal 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
62
Makefile
Normal 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
75
README.md
Normal 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
1
backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Makes backend a package.
|
||||
165
backend/actions.py
Normal file
165
backend/actions.py
Normal 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
112
backend/app.py
Normal 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
107
backend/auth.py
Normal 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
35
backend/models.py
Normal 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
7
backend/requirements.txt
Normal 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
37
backend/settings.py
Normal 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()
|
||||
181
backend/templates/index.html
Normal file
181
backend/templates/index.html
Normal 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
9
deploy_hosts.yml
Normal 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
13
env.example
Normal 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
21
scripts/create_venv.sh
Executable 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
143
scripts/deploy.sh
Normal 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
84
scripts/install.sh
Executable 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
14
scripts/run.sh
Executable 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
23
scripts/update.sh
Executable 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
120
sk.sh
Executable 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
BIN
sk_deploy.zip
Normal file
Binary file not shown.
16
systemd/skd.service
Normal file
16
systemd/skd.service
Normal 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
|
||||
Reference in New Issue
Block a user