diff --git a/.gitignore b/.gitignore index d3ad9ef..761276a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,31 @@ -.venv/ +# Python __pycache__/ *.py[cod] -.pytest_cache/ +*$py.class -data/*.db -data/*.db-* +# Virtual environments +.venv/ +venv/ +env/ +# Environment / secrets .env +.env.* +!.env.example -.idea/ +# Runtime state +data/state.json + +# Logs +*.log +logs/ + +# IDE / editors .vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index 8ca06ab..e54e1ab 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,156 @@ -# Linux Parental Control - -A Linux parental-control system for managing Linux user access, daily time allowances, access windows, temporary grants, and automatic session enforcement. - -> **Status:** Early development - -## Features - -- Per-user daily time allowances -- Different allowances for each day of the week -- Multiple access windows per day -- Temporary time grants -- Usage tracking -- Automatic session termination -- Automatic account locking -- Automatic account unlocking -- Reboot-safe enforcement -- Web-based administration -- SQLite database -- systemd service support - ---- - -# Requirements - -The application currently targets Linux systems using `systemd`. - -You need: - -- Linux -- Python 3 -- `python-venv` -- `pip` -- `systemd` -- `sudo` -- `passwd` -- `loginctl` -- Git - -The enforcement service requires **root privileges** because it manages other Linux users and their sessions. - ---- - -# 1. Clone the Repository - -Clone the repository: - -```bash -git clone https://git.shihaam.dev/Alsan/linux-user-timer.git -``` - -## 2. Make it executable -```bash -chmod +x install.sh -``` - -## 3. Run the install script -```bash -sudo ./install.sh -``` -### You can access it via http://127.0.0.1:8765/admin - - +# Linux Parental Control + +A Linux parental-control system for managing Linux user access, daily time allowances, access windows, temporary grants, and automatic session enforcement. + +> **Status:** Early development + +## Storage + +SQLite has been removed. + +The application now uses two files under `data/`: + +- `config.yaml` — users, daily allowances, access windows, and authentication configuration. +- `state.json` — daily usage, temporary grants, and a bounded event history. + +The application never creates or opens `data/parental-control.db`. + +`config.yaml` and `state.json` are written atomically and are intended to be root-readable only. + +## Web authentication + +The administration web interface is protected by **PAM**. + +Sign in at: + +```text +http://127.0.0.1:8765/admin +``` + +Use an existing Linux username and its Linux password. The password is passed to PAM for authentication and is not stored by this application. + +The PAM service defaults to: + +```yaml +auth: + pam_service: login +``` + +If your distribution uses a different PAM service, change `pam_service` in `data/config.yaml`. + +### Restricting who can use the web panel + +By default, any Linux account that successfully authenticates through PAM can access the web panel. + +For a restricted administration panel, edit `data/config.yaml`: + +```yaml +auth: + pam_service: login + admin_users: + - youradminuser +``` + +Do **not** add a user controlled by the parental-control enforcement system to `admin_users`, because account locking is performed with `passwd`. + +The web session is signed with a randomly generated secret stored in: + +```text +/etc/parental-control/session-secret +/etc/parental-control/session-secret.env +``` + +## Features + +- Per-user daily time allowances +- Different allowances for each day of the week +- Multiple access windows per day +- Temporary time grants +- Usage tracking +- Automatic session termination +- Automatic account locking +- Automatic account unlocking +- Reboot-safe enforcement +- PAM-authenticated web administration +- YAML configuration +- JSON runtime state +- systemd service support + +## Requirements + +The application targets Linux systems using `systemd`. + +You need: + +- Linux +- Python 3 +- `python-venv` +- `pip` +- `systemd` +- PAM +- `sudo` +- `passwd` +- `loginctl` +- Git + +The enforcement service requires **root privileges** because it manages other Linux users and their sessions. + +## Installation + +Clone the repository: + +```bash +git clone https://git.shihaam.dev/Alsan/linux-user-timer.git +cd linux-user-timer +``` + +Make the installer executable: + +```bash +chmod +x install.sh +``` + +Run: + +```bash +sudo ./install.sh +``` + +The installer: + +1. Installs Python dependencies including PyYAML and python-pam. +2. Creates the Python virtual environment. +3. Removes the legacy `data/parental-control.db` if it exists. +4. Initializes `config.yaml` and `state.json`. +5. Generates a random web-session secret. +6. Installs and starts the systemd service. + +## Service commands + +```bash +sudo systemctl status parental-control +sudo systemctl restart parental-control +sudo journalctl -u parental-control -f +``` + +## Reverse proxy / HTTPS + +The application listens on: + +```text +127.0.0.1:8765 +``` + +Put it behind your existing Nginx/Apache/reverse proxy if you want remote access. + +When HTTPS is provided directly to users, set: + +```ini +Environment="PARENTAL_CONTROL_HTTPS_ONLY=1" +``` + +in the systemd service. This marks the session cookie as HTTPS-only. + +## Important security note + +This application controls Linux accounts and runs as root. Do not expose port `8765` directly to an untrusted network. Prefer binding it to localhost and placing it behind an HTTPS reverse proxy with appropriate firewall rules. diff --git a/app/database.py b/app/database.py deleted file mode 100644 index 1ab22fa..0000000 --- a/app/database.py +++ /dev/null @@ -1,105 +0,0 @@ -import sqlite3 -from pathlib import Path -from contextlib import contextmanager - -BASE_DIR = Path(__file__).resolve().parent.parent - -DATABASE_DIR = BASE_DIR / "data" -DATABASE_PATH = DATABASE_DIR / "parental-control.db" - - -def initialize_database(): - DATABASE_DIR.mkdir(parents=True, exist_ok=True) - - with sqlite3.connect(DATABASE_PATH) as db: - db.execute("PRAGMA foreign_keys = ON") - - db.executescript( - """ - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS daily_allowances ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - weekday INTEGER NOT NULL, - allowance_seconds INTEGER NOT NULL DEFAULT 0, - - UNIQUE(user_id, weekday), - - FOREIGN KEY(user_id) - REFERENCES users(id) - ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS access_windows ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - weekday INTEGER NOT NULL, - start_minute INTEGER NOT NULL, - end_minute INTEGER NOT NULL, - - FOREIGN KEY(user_id) - REFERENCES users(id) - ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - date TEXT NOT NULL, - used_seconds INTEGER NOT NULL DEFAULT 0, - - UNIQUE(user_id, date), - - FOREIGN KEY(user_id) - REFERENCES users(id) - ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS temporary_grants ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - seconds INTEGER NOT NULL, - remaining_seconds INTEGER NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TEXT, - consumed INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, - event_type TEXT NOT NULL, - details TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - - FOREIGN KEY(user_id) - REFERENCES users(id) - ON DELETE SET NULL - ); - """ - ) - - db.commit() - - -@contextmanager -def get_db(): - db = sqlite3.connect(DATABASE_PATH) - db.row_factory = sqlite3.Row - db.execute("PRAGMA foreign_keys = ON") - - try: - yield db - db.commit() - except Exception: - db.rollback() - raise - finally: - db.close() diff --git a/app/enforcement.py b/app/enforcement.py index f8e9112..3ee275b 100644 --- a/app/enforcement.py +++ b/app/enforcement.py @@ -1,7 +1,14 @@ from datetime import datetime import subprocess -from .database import get_db +from .storage import ( + get_user_policy, + get_remaining_grant_seconds, + consume_grant_seconds, + record_usage, + record_event, + list_users, +) from .users import ( lock_user, unlock_user, @@ -12,11 +19,7 @@ from .users import ( def user_has_session(username: str) -> bool: result = subprocess.run( - [ - "loginctl", - "list-users", - "--no-legend", - ], + ["loginctl", "list-users", "--no-legend"], capture_output=True, text=True, check=False, @@ -25,297 +28,28 @@ def user_has_session(username: str) -> bool: if result.returncode != 0: return False - for line in result.stdout.splitlines(): - parts = line.split() - - if len(parts) >= 2 and parts[1] == username: - return True - - return False + return any( + len(parts := line.split()) >= 2 and parts[1] == username + for line in result.stdout.splitlines() + ) def current_time(): now = datetime.now() - weekday = now.weekday() - minute = now.hour * 60 + now.minute - - return now, weekday, minute - - -def get_user_policy(user_id: int, weekday: int): - with get_db() as db: - allowance_row = db.execute( - """ - SELECT allowance_seconds - FROM daily_allowances - WHERE user_id = ? - AND weekday = ? - """, - ( - user_id, - weekday, - ), - ).fetchone() - - allowance_seconds = ( - allowance_row["allowance_seconds"] - if allowance_row - else 0 - ) - - today = datetime.now().date().isoformat() - - usage_row = db.execute( - """ - SELECT used_seconds - FROM usage - WHERE user_id = ? - AND date = ? - """, - ( - user_id, - today, - ), - ).fetchone() - - usage_seconds = ( - usage_row["used_seconds"] - if usage_row - else 0 - ) - - windows = db.execute( - """ - SELECT id, start_minute, end_minute - FROM access_windows - WHERE user_id = ? - AND weekday = ? - ORDER BY start_minute - """, - ( - user_id, - weekday, - ), - ).fetchall() - - grant_row = db.execute( - """ - SELECT COALESCE( - SUM(remaining_seconds), - 0 - ) AS total - FROM temporary_grants - WHERE user_id = ? - AND consumed = 0 - AND ( - expires_at IS NULL - OR expires_at > ? - ) - """, - ( - user_id, - datetime.now().isoformat(), - ), - ).fetchone() - - grant_seconds = grant_row["total"] - - return ( - allowance_seconds, - usage_seconds, - windows, - grant_seconds, - ) + return now, now.weekday(), now.hour * 60 + now.minute def is_inside_window(windows, minute: int) -> bool: if not windows: return True - for window in windows: - if ( - window["start_minute"] - <= minute - < window["end_minute"] - ): - return True - - return False + return any( + int(window["start_minute"]) <= minute < int(window["end_minute"]) + for window in windows + ) -def get_remaining_grant_seconds(user_id: int) -> int: - now = datetime.now().isoformat() - - with get_db() as db: - row = db.execute( - """ - SELECT COALESCE( - SUM(remaining_seconds), - 0 - ) AS total - FROM temporary_grants - WHERE user_id = ? - AND consumed = 0 - AND ( - expires_at IS NULL - OR expires_at > ? - ) - """, - ( - user_id, - now, - ), - ).fetchone() - - return row["total"] - - -def consume_grant_seconds( - user_id: int, - seconds: int, -): - if seconds <= 0: - return - - now = datetime.now().isoformat() - - with get_db() as db: - grants = db.execute( - """ - SELECT id, remaining_seconds - FROM temporary_grants - WHERE user_id = ? - AND consumed = 0 - AND remaining_seconds > 0 - AND ( - expires_at IS NULL - OR expires_at > ? - ) - ORDER BY id ASC - """, - ( - user_id, - now, - ), - ).fetchall() - - remaining = seconds - - for grant in grants: - if remaining <= 0: - break - - available = grant["remaining_seconds"] - - consumed = min( - available, - remaining, - ) - - new_remaining = ( - available - consumed - ) - - db.execute( - """ - UPDATE temporary_grants - SET remaining_seconds = ?, - consumed = ? - WHERE id = ? - """, - ( - new_remaining, - 1 if new_remaining <= 0 else 0, - grant["id"], - ), - ) - - remaining -= consumed - - -def record_usage( - user_id: int, - seconds: int, -): - if seconds <= 0: - return - - today = datetime.now().date().isoformat() - - with get_db() as db: - row = db.execute( - """ - SELECT used_seconds - FROM usage - WHERE user_id = ? - AND date = ? - """, - ( - user_id, - today, - ), - ).fetchone() - - if row is None: - db.execute( - """ - INSERT INTO usage ( - user_id, - date, - used_seconds - ) - VALUES (?, ?, ?) - """, - ( - user_id, - today, - seconds, - ), - ) - else: - db.execute( - """ - UPDATE usage - SET used_seconds = - used_seconds + ? - WHERE user_id = ? - AND date = ? - """, - ( - seconds, - user_id, - today, - ), - ) - - -def record_event( - user_id: int, - event_type: str, - details: str = "", -): - with get_db() as db: - db.execute( - """ - INSERT INTO events ( - user_id, - event_type, - details - ) - VALUES (?, ?, ?) - """, - ( - user_id, - event_type, - details, - ), - ) - - -def evaluate_user( - user_id: int, - username: str, -): +def evaluate_user(user_id: int, username: str): now, weekday, minute = current_time() ( @@ -323,43 +57,16 @@ def evaluate_user( usage_seconds, windows, grant_seconds, - ) = get_user_policy( - user_id, - weekday, - ) + ) = get_user_policy(user_id, weekday) - inside_window = is_inside_window( - windows, - minute, - ) + inside_window = is_inside_window(windows, minute) + allowance_remaining = max(0, allowance_seconds - usage_seconds) + total_remaining = allowance_remaining + grant_seconds + logged_in = user_has_session(username) - allowance_remaining = max( - 0, - allowance_seconds - usage_seconds, - ) - - total_remaining = ( - allowance_remaining - + grant_seconds - ) - - logged_in = user_has_session( - username - ) - - allowed_by_schedule = ( - inside_window - and allowance_remaining > 0 - ) - - allowed_by_grant = ( - grant_seconds > 0 - ) - - should_allow = ( - allowed_by_schedule - or allowed_by_grant - ) + allowed_by_schedule = inside_window and allowance_remaining > 0 + allowed_by_grant = grant_seconds > 0 + should_allow = allowed_by_schedule or allowed_by_grant locked = is_locked(username) @@ -367,23 +74,12 @@ def evaluate_user( if locked: try: unlock_user(username) - - record_event( - user_id, - "auto_unlock", - "Access became available", - ) + record_event(user_id, "auto_unlock", "Access became available") except Exception as exc: - record_event( - user_id, - "unlock_error", - str(exc), - ) - + record_event(user_id, "unlock_error", str(exc)) else: if logged_in: terminate_user(username) - record_event( user_id, "session_terminated", @@ -393,18 +89,13 @@ def evaluate_user( if not locked: try: lock_user(username) - record_event( user_id, "auto_lock", "Access is not currently permitted", ) except Exception as exc: - record_event( - user_id, - "lock_error", - str(exc), - ) + record_event(user_id, "lock_error", str(exc)) return { "user_id": user_id, @@ -424,32 +115,13 @@ def evaluate_user( def enforce_all_users(): - with get_db() as db: - users = db.execute( - """ - SELECT id, username, enabled - FROM users - WHERE enabled = 1 - ORDER BY id - """ - ).fetchall() - + users = list_users() results = [] for user in users: try: - result = evaluate_user( - user["id"], - user["username"], - ) - - results.append(result) - + results.append(evaluate_user(user["id"], user["username"])) except Exception as exc: - record_event( - user["id"], - "enforcement_error", - str(exc), - ) + record_event(user["id"], "enforcement_error", str(exc)) return results diff --git a/app/main.py b/app/main.py index 186a315..20d28a1 100644 --- a/app/main.py +++ b/app/main.py @@ -1,31 +1,29 @@ -from fastapi import ( - FastAPI, - HTTPException, - Request, - Form, -) - -from fastapi.responses import ( - RedirectResponse, -) - -from .scheduler import ( - start_scheduler, - stop_scheduler, -) - -from fastapi.templating import ( - Jinja2Templates, -) +import secrets +from pathlib import Path +import pam +from fastapi import FastAPI, HTTPException, Request, Form +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates from pydantic import BaseModel +from starlette.middleware.sessions import SessionMiddleware - -from .database import ( - initialize_database, - get_db, +from .scheduler import start_scheduler, stop_scheduler +from .storage import ( + initialize_storage, + get_config, + get_user, + list_users, + get_user_by_username, + add_user, + delete_user, + set_allowance, + add_window, + delete_window, + add_grant, + list_grants, + is_admin_allowed, ) - from .users import ( linux_user_exists, lock_user, @@ -35,16 +33,28 @@ from .users import ( ) +BASE_DIR = Path(__file__).resolve().parent.parent +SESSION_SECRET = __import__("os").environ.get( + "PARENTAL_CONTROL_SESSION_SECRET" +) or secrets.token_urlsafe(32) + app = FastAPI( title="Parental Control", - version="0.1.0", + version="0.2.0", ) - -templates = Jinja2Templates( - directory="templates" +app.add_middleware( + SessionMiddleware, + secret_key=SESSION_SECRET, + session_cookie="parental_control_session", + max_age=8 * 60 * 60, + same_site="lax", + https_only=__import__("os").environ.get( + "PARENTAL_CONTROL_HTTPS_ONLY", "0" + ) == "1", ) +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) WEEKDAYS = [ (0, "Monday"), @@ -59,7 +69,7 @@ WEEKDAYS = [ @app.on_event("startup") def startup(): - initialize_database() + initialize_storage() start_scheduler() @@ -68,6 +78,53 @@ def shutdown(): stop_scheduler() +def current_user(request: Request): + username = request.session.get("username") + if not username: + return None + + if not is_admin_allowed(username): + request.session.clear() + return None + + return username + + +def require_web_auth(request: Request): + username = current_user(request) + if not username: + next_path = request.url.path + if request.url.query: + next_path += f"?{request.url.query}" + return RedirectResponse( + f"/login?next={next_path}", + status_code=303, + ) + + return None + + +def require_api_auth(request: Request): + username = current_user(request) + if not username: + raise HTTPException(status_code=401, detail="Authentication required") + return username + + +def check_csrf(request: Request, token: str): + expected = request.session.get("csrf_token") + if not expected or not secrets.compare_digest(token, expected): + raise HTTPException(status_code=403, detail="Invalid CSRF token") + + +def csrf_token(request: Request): + token = request.session.get("csrf_token") + if not token: + token = secrets.token_urlsafe(32) + request.session["csrf_token"] = token + return token + + class AllowanceRequest(BaseModel): weekday: int seconds: int @@ -83,358 +140,215 @@ class GrantRequest(BaseModel): seconds: int -def get_user(user_id: int): - with get_db() as db: - return db.execute( - """ - SELECT id, username, enabled - FROM users - WHERE id = ? - """, - (user_id,) - ).fetchone() - - @app.get("/") def root(): return { "application": "Parental Control", - "version": "0.1.0", - "status": "running" + "version": "0.2.0", + "status": "running", + "authentication": "PAM", } -@app.get("/api/users") -def list_users(): - with get_db() as db: - rows = db.execute( - """ - SELECT id, username, enabled - FROM users - ORDER BY username - """ - ).fetchall() +@app.get("/login") +def login_page(request: Request, next: str = "/admin"): + if current_user(request): + return RedirectResponse(next if next.startswith("/") and not next.startswith("//") else "/admin", status_code=303) + return templates.TemplateResponse( + request=request, + name="login.html", + context={ + "next": next if next.startswith("/") else "/admin", + "error": None, + }, + ) + + +@app.post("/login") +def login( + request: Request, + username: str = Form(...), + password: str = Form(...), + next: str = Form("/admin"), +): + username = username.strip() + safe_next = next if next.startswith("/") and not next.startswith("//") else "/admin" + + try: + authenticated = pam.pam().authenticate( + username, + password, + service=get_config()["auth"].get("pam_service", "login"), + ) + except Exception: + authenticated = False + + if not authenticated: + return templates.TemplateResponse( + request=request, + name="login.html", + context={ + "next": safe_next, + "error": "Invalid Linux username or password.", + }, + status_code=401, + ) + + if not is_admin_allowed(username): + return templates.TemplateResponse( + request=request, + name="login.html", + context={ + "next": safe_next, + "error": "This Linux account is not allowed to access the administration panel.", + }, + status_code=403, + ) + + request.session.clear() + request.session["username"] = username + request.session["csrf_token"] = secrets.token_urlsafe(32) + + return RedirectResponse(safe_next, status_code=303) + + +@app.post("/logout") +def logout(request: Request, csrf: str = Form(...)): + if current_user(request): + check_csrf(request, csrf) + request.session.clear() + return RedirectResponse("/login", status_code=303) + + +@app.get("/api/users") +def list_users_api(request: Request): + require_api_auth(request) + users = list_users() return [ { - "id": row["id"], - "username": row["username"], - "enabled": bool(row["enabled"]), - "locked": is_locked(row["username"]) + "id": user["id"], + "username": user["username"], + "enabled": bool(user.get("enabled", True)), + "locked": is_locked(user["username"]), } - for row in rows + for user in users ] @app.post("/api/users/{user_id}/lock") -def manually_lock(user_id: int): +def manually_lock(user_id: int, request: Request): + require_api_auth(request) row = get_user(user_id) - if row is None: - raise HTTPException( - status_code=404, - detail="User not found" - ) - + raise HTTPException(status_code=404, detail="User not found") lock_user(row["username"]) - - return { - "username": row["username"], - "locked": True - } + return {"username": row["username"], "locked": True} @app.post("/api/users/{user_id}/unlock") -def manually_unlock(user_id: int): +def manually_unlock(user_id: int, request: Request): + require_api_auth(request) row = get_user(user_id) - if row is None: - raise HTTPException( - status_code=404, - detail="User not found" - ) - + raise HTTPException(status_code=404, detail="User not found") unlock_user(row["username"]) - - return { - "username": row["username"], - "locked": False - } + return {"username": row["username"], "locked": False} @app.post("/api/users/{user_id}/terminate") -def manually_terminate(user_id: int): +def manually_terminate(user_id: int, request: Request): + require_api_auth(request) row = get_user(user_id) - if row is None: - raise HTTPException( - status_code=404, - detail="User not found" - ) - + raise HTTPException(status_code=404, detail="User not found") terminate_user(row["username"]) - - return { - "username": row["username"], - "terminated": True - } + return {"username": row["username"], "terminated": True} @app.post("/api/users/{user_id}/allowance") -def set_allowance( - user_id: int, - request: AllowanceRequest -): +def set_allowance_api(user_id: int, request: Request, body: AllowanceRequest): + require_api_auth(request) if get_user(user_id) is None: - raise HTTPException( - status_code=404, - detail="User not found" - ) - - if request.weekday < 0 or request.weekday > 6: - raise HTTPException( - status_code=400, - detail="Invalid weekday" - ) - - if request.seconds < 0: - raise HTTPException( - status_code=400, - detail="Allowance cannot be negative" - ) - - with get_db() as db: - db.execute( - """ - INSERT INTO daily_allowances - ( - user_id, - weekday, - allowance_seconds - ) - VALUES (?, ?, ?) - ON CONFLICT(user_id, weekday) - DO UPDATE SET - allowance_seconds = excluded.allowance_seconds - """, - ( - user_id, - request.weekday, - request.seconds - ) - ) - - return { - "user_id": user_id, - "weekday": request.weekday, - "seconds": request.seconds - } + raise HTTPException(status_code=404, detail="User not found") + if body.weekday not in range(7) or body.seconds < 0: + raise HTTPException(status_code=400, detail="Invalid allowance") + set_allowance(user_id, body.weekday, body.seconds) + return {"user_id": user_id, "weekday": body.weekday, "seconds": body.seconds} @app.post("/api/users/{user_id}/windows") -def add_window( - user_id: int, - request: WindowRequest -): +def add_window_api(user_id: int, request: Request, body: WindowRequest): + require_api_auth(request) if get_user(user_id) is None: - raise HTTPException( - status_code=404, - detail="User not found" - ) - - if request.weekday < 0 or request.weekday > 6: - raise HTTPException( - status_code=400, - detail="Invalid weekday" - ) - - if request.start_minute < 0 or request.start_minute >= 1440: - raise HTTPException( - status_code=400, - detail="Invalid start time" - ) - - if request.end_minute < 0 or request.end_minute > 1440: - raise HTTPException( - status_code=400, - detail="Invalid end time" - ) - - if request.end_minute <= request.start_minute: - raise HTTPException( - status_code=400, - detail="End time must be after start time" - ) - - with get_db() as db: - db.execute( - """ - INSERT INTO access_windows - ( - user_id, - weekday, - start_minute, - end_minute - ) - VALUES (?, ?, ?, ?) - """, - ( - user_id, - request.weekday, - request.start_minute, - request.end_minute - ) - ) - - return { - "status": "created" - } + raise HTTPException(status_code=404, detail="User not found") + if body.weekday not in range(7): + raise HTTPException(status_code=400, detail="Invalid weekday") + if not (0 <= body.start_minute < 1440): + raise HTTPException(status_code=400, detail="Invalid start time") + if not (0 <= body.end_minute <= 1440) or body.end_minute <= body.start_minute: + raise HTTPException(status_code=400, detail="Invalid end time") + add_window(user_id, body.weekday, body.start_minute, body.end_minute) + return {"status": "created"} @app.delete("/api/windows/{window_id}") -def delete_window(window_id: int): - with get_db() as db: - cursor = db.execute( - """ - DELETE FROM access_windows - WHERE id = ? - """, - (window_id,) - ) - - if cursor.rowcount == 0: - raise HTTPException( - status_code=404, - detail="Window not found" - ) - - return { - "status": "deleted" - } +def delete_window_api(window_id: int, request: Request): + require_api_auth(request) + user_id = delete_window(window_id) + if user_id is None: + raise HTTPException(status_code=404, detail="Window not found") + return {"status": "deleted"} @app.post("/api/users/{user_id}/grant") -def grant_time( - user_id: int, - request: GrantRequest -): +def grant_time_api(user_id: int, request: Request, body: GrantRequest): + require_api_auth(request) if get_user(user_id) is None: - raise HTTPException( - status_code=404, - detail="User not found" - ) - - if request.seconds <= 0: - raise HTTPException( - status_code=400, - detail="Grant must be greater than zero" - ) - - with get_db() as db: - db.execute( - """ - INSERT INTO temporary_grants - ( - user_id, - seconds, - remaining_seconds - ) - VALUES (?, ?, ?) - """, - ( - user_id, - request.seconds, - request.seconds - ) - ) - return { - "user_id": user_id, - "seconds": request.seconds - } + raise HTTPException(status_code=404, detail="User not found") + if body.seconds <= 0: + raise HTTPException(status_code=400, detail="Grant must be greater than zero") + add_grant(user_id, body.seconds) + return {"user_id": user_id, "seconds": body.seconds} @app.get("/admin") -def admin_page( - request: Request -): - with get_db() as db: - users = db.execute( - """ - SELECT id, username, enabled - FROM users - ORDER BY username - """ - ).fetchall() +def admin_page(request: Request): + redirect = require_web_auth(request) + if redirect: + return redirect return templates.TemplateResponse( request=request, name="index.html", context={ - "users": users - } + "users": list_users(), + "username": current_user(request), + "csrf_token": csrf_token(request), + }, ) @app.get("/admin/users/{user_id}") -def admin_user_page( - request: Request, - user_id: int -): +def admin_user_page(request: Request, user_id: int): + redirect = require_web_auth(request) + if redirect: + return redirect + user = get_user(user_id) - if user is None: - raise HTTPException( - status_code=404, - detail="User not found" - ) + raise HTTPException(status_code=404, detail="User not found") - with get_db() as db: - - allowances = db.execute( - """ - SELECT weekday, allowance_seconds - FROM daily_allowances - WHERE user_id = ? - ORDER BY weekday - """, - (user_id,) - ).fetchall() - - windows = db.execute( - """ - SELECT - id, - weekday, - start_minute, - end_minute - FROM access_windows - WHERE user_id = ? - ORDER BY weekday, start_minute - """, - (user_id,) - ).fetchall() - - grants = db.execute( - """ - SELECT - id, - seconds, - created_at, - expires_at, - consumed - FROM temporary_grants - WHERE user_id = ? - ORDER BY id DESC - LIMIT 20 - """, - (user_id,) - ).fetchall() - - allowance_map = { - row["weekday"]: row["allowance_seconds"] - for row in allowances + allowances = { + int(day): int(seconds) + for day, seconds in user.get("allowances", {}).items() } + windows = sorted( + [dict(window) for window in user.get("windows", [])], + key=lambda item: (int(item["weekday"]), int(item["start_minute"])), + ) + return templates.TemplateResponse( request=request, name="user.html", @@ -442,376 +356,183 @@ def admin_user_page( "user": user, "locked": is_locked(user["username"]), "weekdays": WEEKDAYS, - "allowances": allowance_map, + "allowances": allowances, "windows": windows, - "grants": grants, - } + "grants": list_grants(user_id), + "csrf_token": csrf_token(request), + "username": current_user(request), + }, ) @app.post("/admin/users/{user_id}/allowance") def admin_set_allowance( + request: Request, user_id: int, + csrf: str = Form(...), weekday: int = Form(...), hours: int = Form(...), minutes: int = Form(...), ): + redirect = require_web_auth(request) + if redirect: + return redirect + check_csrf(request, csrf) + if get_user(user_id) is None: - raise HTTPException( - status_code=404, - detail="User not found" - ) + raise HTTPException(status_code=404, detail="User not found") + if weekday not in range(7) or hours < 0 or minutes < 0 or minutes > 59: + raise HTTPException(status_code=400, detail="Invalid time") - if weekday < 0 or weekday > 6: - raise HTTPException( - status_code=400, - detail="Invalid weekday" - ) - - if hours < 0 or minutes < 0 or minutes > 59: - raise HTTPException( - status_code=400, - detail="Invalid time" - ) - - seconds = (hours * 3600) + (minutes * 60) - - with get_db() as db: - db.execute( - """ - INSERT INTO daily_allowances - ( - user_id, - weekday, - allowance_seconds - ) - VALUES (?, ?, ?) - ON CONFLICT(user_id, weekday) - DO UPDATE SET - allowance_seconds = excluded.allowance_seconds - """, - ( - user_id, - weekday, - seconds - ) - ) - - return RedirectResponse( - f"/admin/users/{user_id}", - status_code=303 - ) + set_allowance(user_id, weekday, hours * 3600 + minutes * 60) + return RedirectResponse(f"/admin/users/{user_id}", status_code=303) @app.post("/admin/users/{user_id}/window") def admin_add_window( + request: Request, user_id: int, + csrf: str = Form(...), weekday: int = Form(...), start_time: str = Form(...), end_time: str = Form(...), ): + redirect = require_web_auth(request) + if redirect: + return redirect + check_csrf(request, csrf) + if get_user(user_id) is None: - raise HTTPException( - status_code=404, - detail="User not found" - ) + raise HTTPException(status_code=404, detail="User not found") try: - start_hour, start_minute = map( - int, - start_time.split(":") - ) - - end_hour, end_minute = map( - int, - end_time.split(":") - ) + start_hour, start_minute = map(int, start_time.split(":")) + end_hour, end_minute = map(int, end_time.split(":")) except ValueError: - raise HTTPException( - status_code=400, - detail="Invalid time format" - ) + raise HTTPException(status_code=400, detail="Invalid time format") - start_total = ( - start_hour * 60 - + start_minute - ) + start_total = start_hour * 60 + start_minute + end_total = end_hour * 60 + end_minute - end_total = ( - end_hour * 60 - + end_minute - ) + if weekday not in range(7) or not (0 <= start_total < 1440): + raise HTTPException(status_code=400, detail="Invalid start time") + if not (0 <= end_total <= 1440) or end_total <= start_total: + raise HTTPException(status_code=400, detail="End time must be after start time") - if weekday < 0 or weekday > 6: - raise HTTPException( - status_code=400, - detail="Invalid weekday" - ) - - if start_total < 0 or start_total >= 1440: - raise HTTPException( - status_code=400, - detail="Invalid start time" - ) - - if end_total <= start_total: - raise HTTPException( - status_code=400, - detail="End time must be after start time" - ) - - with get_db() as db: - db.execute( - """ - INSERT INTO access_windows - ( - user_id, - weekday, - start_minute, - end_minute - ) - VALUES (?, ?, ?, ?) - """, - ( - user_id, - weekday, - start_total, - end_total - ) - ) - - return RedirectResponse( - f"/admin/users/{user_id}", - status_code=303 - ) + add_window(user_id, weekday, start_total, end_total) + return RedirectResponse(f"/admin/users/{user_id}", status_code=303) @app.post("/admin/windows/{window_id}/delete") -def admin_delete_window( - window_id: int -): - with get_db() as db: - row = db.execute( - """ - SELECT user_id - FROM access_windows - WHERE id = ? - """, - (window_id,) - ).fetchone() +def admin_delete_window(request: Request, window_id: int, csrf: str = Form(...)): + redirect = require_web_auth(request) + if redirect: + return redirect + check_csrf(request, csrf) - if row is None: - raise HTTPException( - status_code=404, - detail="Window not found" - ) + user_id = delete_window(window_id) + if user_id is None: + raise HTTPException(status_code=404, detail="Window not found") - user_id = row["user_id"] - - db.execute( - """ - DELETE FROM access_windows - WHERE id = ? - """, - (window_id,) - ) - - return RedirectResponse( - f"/admin/users/{user_id}", - status_code=303 - ) + return RedirectResponse(f"/admin/users/{user_id}", status_code=303) @app.post("/admin/users/{user_id}/grant") def admin_grant_time( + request: Request, user_id: int, + csrf: str = Form(...), hours: int = Form(...), minutes: int = Form(...), ): + redirect = require_web_auth(request) + if redirect: + return redirect + check_csrf(request, csrf) + if get_user(user_id) is None: - raise HTTPException( - status_code=404, - detail="User not found" - ) - + raise HTTPException(status_code=404, detail="User not found") if hours < 0 or minutes < 0 or minutes > 59: - raise HTTPException( - status_code=400, - detail="Invalid time" - ) - - seconds = ( - hours * 3600 - + minutes * 60 - ) + raise HTTPException(status_code=400, detail="Invalid time") + seconds = hours * 3600 + minutes * 60 if seconds <= 0: - raise HTTPException( - status_code=400, - detail="Grant must be greater than zero" - ) + raise HTTPException(status_code=400, detail="Grant must be greater than zero") - with get_db() as db: - db.execute( - """ - INSERT INTO temporary_grants - ( - user_id, - seconds, - remaining_seconds - ) - VALUES (?, ?, ?) - """, - ( - user_id, - seconds, - seconds - ) - ) - - return RedirectResponse( - f"/admin/users/{user_id}", - status_code=303 - ) + add_grant(user_id, seconds) + return RedirectResponse(f"/admin/users/{user_id}", status_code=303) @app.post("/admin/users/{user_id}/lock") -def admin_lock_user( - user_id: int -): +def admin_lock_user(request: Request, user_id: int, csrf: str = Form(...)): + redirect = require_web_auth(request) + if redirect: + return redirect + check_csrf(request, csrf) + row = get_user(user_id) - if row is None: - raise HTTPException( - status_code=404, - detail="User not found" - ) - + raise HTTPException(status_code=404, detail="User not found") lock_user(row["username"]) - - return RedirectResponse( - f"/admin/users/{user_id}", - status_code=303 - ) + return RedirectResponse(f"/admin/users/{user_id}", status_code=303) @app.post("/admin/users/{user_id}/unlock") -def admin_unlock_user( - user_id: int -): +def admin_unlock_user(request: Request, user_id: int, csrf: str = Form(...)): + redirect = require_web_auth(request) + if redirect: + return redirect + check_csrf(request, csrf) + row = get_user(user_id) - if row is None: - raise HTTPException( - status_code=404, - detail="User not found" - ) - + raise HTTPException(status_code=404, detail="User not found") unlock_user(row["username"]) - - return RedirectResponse( - f"/admin/users/{user_id}", - status_code=303 - ) + return RedirectResponse(f"/admin/users/{user_id}", status_code=303) @app.post("/admin/users/{user_id}/terminate") -def admin_terminate_user( - user_id: int -): +def admin_terminate_user(request: Request, user_id: int, csrf: str = Form(...)): + redirect = require_web_auth(request) + if redirect: + return redirect + check_csrf(request, csrf) + row = get_user(user_id) - if row is None: - raise HTTPException( - status_code=404, - detail="User not found" - ) - + raise HTTPException(status_code=404, detail="User not found") terminate_user(row["username"]) - - return RedirectResponse( - f"/admin/users/{user_id}", - status_code=303 - ) + return RedirectResponse(f"/admin/users/{user_id}", status_code=303) @app.post("/admin/users/{user_id}/delete") -def delete_user( - user_id: int -): - with get_db() as db: - db.execute( - """ - DELETE FROM users - WHERE id = ? - """, - (user_id,) - ) +def delete_user_admin(request: Request, user_id: int, csrf: str = Form(...)): + redirect = require_web_auth(request) + if redirect: + return redirect + check_csrf(request, csrf) - return RedirectResponse( - "/admin", - status_code=303 - ) + delete_user(user_id) + return RedirectResponse("/admin", status_code=303) @app.post("/admin/users") def admin_add_user( - username: str = Form(...) + request: Request, + csrf: str = Form(...), + username: str = Form(...), ): + redirect = require_web_auth(request) + if redirect: + return redirect + check_csrf(request, csrf) + username = username.strip() - if not linux_user_exists(username): - raise HTTPException( - status_code=400, - detail="Linux user does not exist" - ) + raise HTTPException(status_code=400, detail="Linux user does not exist") - with get_db() as db: + if get_user_by_username(username) is not None: + raise HTTPException(status_code=400, detail="User is already configured") - existing = db.execute( - """ - SELECT id - FROM users - WHERE username = ? - """, - (username,) - ).fetchone() - - if existing is not None: - raise HTTPException( - status_code=400, - detail="User is already configured" - ) - - cursor = db.execute( - """ - INSERT INTO users(username) - VALUES(?) - """, - (username,) - ) - - user_id = cursor.lastrowid - - for weekday in range(7): - db.execute( - """ - INSERT INTO daily_allowances - ( - user_id, - weekday, - allowance_seconds - ) - VALUES (?, ?, ?) - """, - ( - user_id, - weekday, - 0 - ) - ) - - return RedirectResponse( - "/admin", - status_code=303 - ) + add_user(username) + return RedirectResponse("/admin", status_code=303) diff --git a/app/scheduler.py b/app/scheduler.py index 8c7876a..bfdf1b5 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -2,12 +2,8 @@ import threading import time from datetime import datetime -from .enforcement import ( - enforce_all_users, - record_usage, - get_user_policy, - consume_grant_seconds, -) +from .enforcement import enforce_all_users +from .storage import record_usage, get_user_policy, consume_grant_seconds CHECK_INTERVAL = 5 diff --git a/app/storage.py b/app/storage.py new file mode 100644 index 0000000..da29f39 --- /dev/null +++ b/app/storage.py @@ -0,0 +1,445 @@ +import json +import os +import tempfile +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path +from threading import RLock + +import yaml + +BASE_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = BASE_DIR / "data" +CONFIG_PATH = DATA_DIR / "config.yaml" +STATE_PATH = DATA_DIR / "state.json" + +_lock = RLock() + +DEFAULT_CONFIG = { + "version": 1, + "auth": { + "pam_service": "login", + "admin_users": [], + }, + "users": [], +} + +DEFAULT_STATE = { + "version": 1, + "next_ids": { + "user": 1, + "window": 1, + "grant": 1, + }, + "usage": {}, + "temporary_grants": [], + "events": [], +} + + +def _atomic_write(path: Path, content: str, mode: int = 0o600): + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{path.name}.", + dir=str(path.parent), + text=True, + ) + try: + os.fchmod(fd, mode) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_name, path) + finally: + if os.path.exists(tmp_name): + os.unlink(tmp_name) + + +def _load_yaml(): + if not CONFIG_PATH.exists(): + return json.loads(json.dumps(DEFAULT_CONFIG)) + + with CONFIG_PATH.open("r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + + if not isinstance(data, dict): + raise ValueError("config.yaml must contain a YAML object") + + data.setdefault("version", 1) + data.setdefault("auth", {}) + data["auth"].setdefault("pam_service", "login") + data["auth"].setdefault("admin_users", []) + data.setdefault("users", []) + return data + + +def _load_json(): + if not STATE_PATH.exists(): + return json.loads(json.dumps(DEFAULT_STATE)) + + with STATE_PATH.open("r", encoding="utf-8") as handle: + data = json.load(handle) + + if not isinstance(data, dict): + raise ValueError("state.json must contain a JSON object") + + data.setdefault("version", 1) + data.setdefault("next_ids", {}) + data["next_ids"].setdefault("user", 1) + data["next_ids"].setdefault("window", 1) + data["next_ids"].setdefault("grant", 1) + data.setdefault("usage", {}) + data.setdefault("temporary_grants", []) + data.setdefault("events", []) + return data + + +def _save_yaml(data): + _atomic_write( + CONFIG_PATH, + yaml.safe_dump( + data, + sort_keys=False, + default_flow_style=False, + allow_unicode=True, + ), + ) + + +def _save_json(data): + _atomic_write( + STATE_PATH, + json.dumps(data, indent=2, ensure_ascii=False) + "\n", + ) + + +def initialize_storage(): + DATA_DIR.mkdir(parents=True, exist_ok=True) + + with _lock: + if not CONFIG_PATH.exists(): + _save_yaml(DEFAULT_CONFIG) + + if not STATE_PATH.exists(): + _save_json(DEFAULT_STATE) + + # Keep files usable after manual edits while avoiding destructive + # initialization or recreation of any database. + config = _load_yaml() + state = _load_json() + _save_yaml(config) + _save_json(state) + + +def get_config(): + with _lock: + return _load_yaml() + + +def get_pam_service(): + return get_config()["auth"].get("pam_service", "login") + + +def admin_users(): + value = get_config()["auth"].get("admin_users", []) + return {str(item) for item in value} + + +def is_admin_allowed(username: str) -> bool: + allowed = admin_users() + return not allowed or username in allowed + + +def _find_user(config, user_id): + for user in config["users"]: + if int(user["id"]) == int(user_id): + return user + return None + + +def _find_window(config, window_id): + for user in config["users"]: + for window in user.get("windows", []): + if int(window["id"]) == int(window_id): + return user, window + return None, None + + +def list_users(): + with _lock: + config = _load_yaml() + return sorted( + [dict(user) for user in config["users"] if user.get("enabled", True)], + key=lambda item: item["username"], + ) + + +def get_user(user_id: int): + with _lock: + config = _load_yaml() + user = _find_user(config, user_id) + return dict(user) if user else None + + +def get_user_by_username(username: str): + with _lock: + config = _load_yaml() + for user in config["users"]: + if user["username"] == username: + return dict(user) + return None + + +def add_user(username: str): + with _lock: + config = _load_yaml() + if any(u["username"] == username for u in config["users"]): + raise ValueError("User is already configured") + + state = _load_json() + user_id = int(state["next_ids"]["user"]) + state["next_ids"]["user"] = user_id + 1 + + user = { + "id": user_id, + "username": username, + "enabled": True, + "allowances": {str(day): 0 for day in range(7)}, + "windows": [], + } + config["users"].append(user) + _save_yaml(config) + _save_json(state) + return dict(user) + + +def delete_user(user_id: int): + with _lock: + config = _load_yaml() + user = _find_user(config, user_id) + if user is None: + return False + + username = user["username"] + config["users"] = [ + item for item in config["users"] + if int(item["id"]) != int(user_id) + ] + + state = _load_json() + state["usage"] = { + key: value + for key, value in state["usage"].items() + if not key.startswith(f"{int(user_id)}:") + } + state["temporary_grants"] = [ + grant for grant in state["temporary_grants"] + if int(grant["user_id"]) != int(user_id) + ] + state["events"] = [ + event for event in state["events"] + if event.get("user_id") is None + or int(event["user_id"]) != int(user_id) + ] + + _save_yaml(config) + _save_json(state) + return username + + +def set_allowance(user_id: int, weekday: int, seconds: int): + with _lock: + config = _load_yaml() + user = _find_user(config, user_id) + if user is None: + raise KeyError("User not found") + + user.setdefault("allowances", {}) + user["allowances"][str(weekday)] = int(seconds) + _save_yaml(config) + + +def add_window(user_id: int, weekday: int, start_minute: int, end_minute: int): + with _lock: + config = _load_yaml() + user = _find_user(config, user_id) + if user is None: + raise KeyError("User not found") + + state = _load_json() + window_id = int(state["next_ids"]["window"]) + state["next_ids"]["window"] = window_id + 1 + + user.setdefault("windows", []).append({ + "id": window_id, + "weekday": int(weekday), + "start_minute": int(start_minute), + "end_minute": int(end_minute), + }) + + _save_yaml(config) + _save_json(state) + return window_id + + +def delete_window(window_id: int): + with _lock: + config = _load_yaml() + owner, window = _find_window(config, window_id) + if owner is None: + return None + + owner["windows"] = [ + item for item in owner.get("windows", []) + if int(item["id"]) != int(window_id) + ] + _save_yaml(config) + return int(owner["id"]) + + +def get_user_policy(user_id: int, weekday: int): + with _lock: + config = _load_yaml() + user = _find_user(config, user_id) + if user is None: + return 0, 0, [], 0 + + allowance_seconds = int( + user.get("allowances", {}).get(str(weekday), 0) + ) + windows = sorted( + [ + dict(window) + for window in user.get("windows", []) + if int(window["weekday"]) == int(weekday) + ], + key=lambda item: int(item["start_minute"]), + ) + + state = _load_json() + today = datetime.now().date().isoformat() + usage_seconds = int( + state["usage"].get(f"{int(user_id)}:{today}", 0) + ) + + now = datetime.now().isoformat() + grant_seconds = sum( + int(grant["remaining_seconds"]) + for grant in state["temporary_grants"] + if int(grant["user_id"]) == int(user_id) + and not grant.get("consumed", False) + and ( + grant.get("expires_at") is None + or grant["expires_at"] > now + ) + ) + + return allowance_seconds, usage_seconds, windows, grant_seconds + + +def get_remaining_grant_seconds(user_id: int) -> int: + with _lock: + state = _load_json() + now = datetime.now().isoformat() + return sum( + int(grant["remaining_seconds"]) + for grant in state["temporary_grants"] + if int(grant["user_id"]) == int(user_id) + and not grant.get("consumed", False) + and int(grant["remaining_seconds"]) > 0 + and ( + grant.get("expires_at") is None + or grant["expires_at"] > now + ) + ) + + +def add_grant(user_id: int, seconds: int): + with _lock: + state = _load_json() + grant_id = int(state["next_ids"]["grant"]) + state["next_ids"]["grant"] = grant_id + 1 + state["temporary_grants"].append({ + "id": grant_id, + "user_id": int(user_id), + "seconds": int(seconds), + "remaining_seconds": int(seconds), + "created_at": datetime.now().isoformat(timespec="seconds"), + "expires_at": None, + "consumed": False, + }) + _save_json(state) + return grant_id + + +def consume_grant_seconds(user_id: int, seconds: int): + if seconds <= 0: + return + + with _lock: + state = _load_json() + now = datetime.now().isoformat() + remaining = int(seconds) + + for grant in state["temporary_grants"]: + if remaining <= 0: + break + if int(grant["user_id"]) != int(user_id): + continue + if grant.get("consumed", False): + continue + if int(grant["remaining_seconds"]) <= 0: + continue + if ( + grant.get("expires_at") is not None + and grant["expires_at"] <= now + ): + continue + + available = int(grant["remaining_seconds"]) + consumed = min(available, remaining) + new_remaining = available - consumed + grant["remaining_seconds"] = new_remaining + grant["consumed"] = new_remaining <= 0 + remaining -= consumed + + _save_json(state) + + +def record_usage(user_id: int, seconds: int): + if seconds <= 0: + return + + with _lock: + state = _load_json() + today = datetime.now().date().isoformat() + key = f"{int(user_id)}:{today}" + state["usage"][key] = int(state["usage"].get(key, 0)) + int(seconds) + _save_json(state) + + +def list_grants(user_id: int, limit: int = 20): + with _lock: + state = _load_json() + grants = [ + dict(grant) + for grant in state["temporary_grants"] + if int(grant["user_id"]) == int(user_id) + ] + grants.sort(key=lambda item: int(item["id"]), reverse=True) + return grants[:limit] + + +def record_event(user_id, event_type: str, details: str = ""): + with _lock: + state = _load_json() + state["events"].append({ + "user_id": int(user_id) if user_id is not None else None, + "event_type": event_type, + "details": details, + "created_at": datetime.now().isoformat(timespec="seconds"), + }) + # Keep the state file bounded. + state["events"] = state["events"][-2000:] + _save_json(state) diff --git a/data/config.yaml b/data/config.yaml new file mode 100644 index 0000000..18ec808 --- /dev/null +++ b/data/config.yaml @@ -0,0 +1,6 @@ +version: 1 +auth: + pam_service: login + admin_users: + - root +users: [] diff --git a/install.sh b/install.sh index 738932a..9b431cd 100755 --- a/install.sh +++ b/install.sh @@ -156,6 +156,7 @@ case "$PACKAGE_MANAGER" in esac + # ============================================================ # Verify Python # ============================================================ @@ -200,6 +201,27 @@ echo "[3/5] Installing Python packages" -r "$INSTALL_DIR/requirements.txt" +# ============================================================ +# Remove legacy SQLite storage +# ============================================================ + +LEGACY_DB="$INSTALL_DIR/data/parental-control.db" + +if [ -f "$LEGACY_DB" ]; then + echo + echo "Removing legacy SQLite database:" + echo " $LEGACY_DB" + rm -f "$LEGACY_DB" +fi + +mkdir -p "$INSTALL_DIR/data" +chmod 700 "$INSTALL_DIR/data" + +# Keep the configuration and runtime state readable/writable only by root. +[ -f "$INSTALL_DIR/data/config.yaml" ] && chmod 600 "$INSTALL_DIR/data/config.yaml" +[ -f "$INSTALL_DIR/data/state.json" ] && chmod 600 "$INSTALL_DIR/data/state.json" + + # ============================================================ # Install systemd service # ============================================================ @@ -208,6 +230,23 @@ echo echo "[4/5] Installing systemd service" SERVICE_FILE="/etc/systemd/system/parental-control.service" +SECRET_DIR="/etc/parental-control" +SECRET_FILE="$SECRET_DIR/session-secret" +SECRET_ENV_FILE="$SECRET_DIR/session-secret.env" + +install -d -m 700 "$SECRET_DIR" + +if [ ! -s "$SECRET_FILE" ]; then + "$INSTALL_DIR/.venv/bin/python" -c \ + 'import secrets; print(secrets.token_urlsafe(48))' \ + > "$SECRET_FILE" + chmod 600 "$SECRET_FILE" +fi + +# systemd reads the environment file; the raw secret is kept separately +# so it is easy to rotate without changing the service definition. +printf 'PARENTAL_CONTROL_SESSION_SECRET=%s\n' "$(cat "$SECRET_FILE")" > "$SECRET_ENV_FILE" +chmod 600 "$SECRET_ENV_FILE" sed \ "s|%INSTALL_DIR%|$INSTALL_DIR|g" \ @@ -215,6 +254,7 @@ sed \ > "$SERVICE_FILE" + # ============================================================ # Enable and start service # ============================================================ diff --git a/parental-control.service b/parental-control.service index 59f5791..1c6007d 100644 --- a/parental-control.service +++ b/parental-control.service @@ -9,8 +9,9 @@ WorkingDirectory=%INSTALL_DIR% Environment="PATH=%INSTALL_DIR%/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin" Environment="PARENTAL_CONTROL_ENFORCEMENT=1" +EnvironmentFile=-/etc/parental-control/session-secret.env -ExecStart=%INSTALL_DIR%/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8765 +ExecStart=%INSTALL_DIR%/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8765 Restart=always RestartSec=5 diff --git a/requirements.txt b/requirements.txt index 335467c..4c972f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,10 +7,13 @@ h11==0.16.0 idna==3.19 Jinja2==3.1.6 MarkupSafe==3.0.3 +python-pam>=2.0.2 pydantic==2.13.5 pydantic_core==2.46.5 +PyYAML>=6.0 python-multipart==0.0.32 starlette==1.6.0 typing-inspection==0.4.4 typing_extensions==4.16.0 uvicorn==0.53.0 +itsdangerous diff --git a/templates/index.html b/templates/index.html index 959863c..f62b9d2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -254,6 +254,14 @@ Parental Control +
Signed in as {{ username }} +
+ + + +
+
+ @@ -345,6 +353,7 @@ method="post" action="/admin/users/{{ user.id }}/delete" > + + + +

The application does not store your Linux password.

+ + + diff --git a/templates/user.html b/templates/user.html index ba162ce..4ee9990 100644 --- a/templates/user.html +++ b/templates/user.html @@ -302,6 +302,14 @@ Parental Control +
Signed in as {{ username }} +
+ + + +
+
+
@@ -359,6 +367,7 @@ method="post" action="/admin/users/{{ user.id }}/unlock" > +