first commit

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

1
backend/__init__.py Normal file
View File

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

165
backend/actions.py Normal file
View File

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

112
backend/app.py Normal file
View File

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

107
backend/auth.py Normal file
View File

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

35
backend/models.py Normal file
View File

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

7
backend/requirements.txt Normal file
View File

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

37
backend/settings.py Normal file
View File

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

View File

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