From c4f01044e9b898ea90a9508d13952e8243688009 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 17 Sep 2026 03:43:51 +0500 Subject: [PATCH] first commit --- README.md | 52 +++ app/__init__.py | 0 app/database.py | 105 +++++ app/enforcement.py | 455 +++++++++++++++++++++ app/main.py | 817 +++++++++++++++++++++++++++++++++++++ app/models.py | 34 ++ app/scheduler.py | 183 +++++++++ app/users.py | 59 +++ data/.gitkeep | 0 install.sh | 58 +++ parental-control.service | 22 + readme.md | 52 +++ requirements.txt | 16 + templates/index.html | 435 ++++++++++++++++++++ templates/user.html | 859 +++++++++++++++++++++++++++++++++++++++ 15 files changed, 3147 insertions(+) create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/database.py create mode 100644 app/enforcement.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/scheduler.py create mode 100644 app/users.py create mode 100644 data/.gitkeep create mode 100755 install.sh create mode 100644 parental-control.service create mode 100644 readme.md create mode 100644 requirements.txt create mode 100644 templates/index.html create mode 100644 templates/user.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..34ff06f --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# 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://github.com/Alsantek-me/linux-user-timer.git +``` + +# 2. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..1ab22fa --- /dev/null +++ b/app/database.py @@ -0,0 +1,105 @@ +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 new file mode 100644 index 0000000..f8e9112 --- /dev/null +++ b/app/enforcement.py @@ -0,0 +1,455 @@ +from datetime import datetime +import subprocess + +from .database import get_db +from .users import ( + lock_user, + unlock_user, + terminate_user, + is_locked, +) + + +def user_has_session(username: str) -> bool: + result = subprocess.run( + [ + "loginctl", + "list-users", + "--no-legend", + ], + capture_output=True, + text=True, + check=False, + ) + + 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 + + +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, + ) + + +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 + + +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, +): + now, weekday, minute = current_time() + + ( + allowance_seconds, + usage_seconds, + windows, + grant_seconds, + ) = get_user_policy( + user_id, + weekday, + ) + + 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 + ) + + 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) + + if should_allow: + if locked: + try: + unlock_user(username) + + record_event( + user_id, + "auto_unlock", + "Access became available", + ) + except Exception as exc: + record_event( + user_id, + "unlock_error", + str(exc), + ) + + else: + if logged_in: + terminate_user(username) + + record_event( + user_id, + "session_terminated", + "Access is not currently permitted", + ) + + 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), + ) + + return { + "user_id": user_id, + "username": username, + "timestamp": now.isoformat(), + "weekday": weekday, + "minute": minute, + "inside_window": inside_window, + "logged_in": logged_in, + "allowance_seconds": allowance_seconds, + "usage_seconds": usage_seconds, + "allowance_remaining": allowance_remaining, + "grant_seconds": grant_seconds, + "total_remaining": total_remaining, + "allowed": should_allow, + } + + +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() + + results = [] + + for user in users: + try: + result = evaluate_user( + user["id"], + user["username"], + ) + + results.append(result) + + except Exception as exc: + record_event( + user["id"], + "enforcement_error", + str(exc), + ) + + return results diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..186a315 --- /dev/null +++ b/app/main.py @@ -0,0 +1,817 @@ +from fastapi import ( + FastAPI, + HTTPException, + Request, + Form, +) + +from fastapi.responses import ( + RedirectResponse, +) + +from .scheduler import ( + start_scheduler, + stop_scheduler, +) + +from fastapi.templating import ( + Jinja2Templates, +) + +from pydantic import BaseModel + + +from .database import ( + initialize_database, + get_db, +) + +from .users import ( + linux_user_exists, + lock_user, + unlock_user, + terminate_user, + is_locked, +) + + +app = FastAPI( + title="Parental Control", + version="0.1.0", +) + + +templates = Jinja2Templates( + directory="templates" +) + + +WEEKDAYS = [ + (0, "Monday"), + (1, "Tuesday"), + (2, "Wednesday"), + (3, "Thursday"), + (4, "Friday"), + (5, "Saturday"), + (6, "Sunday"), +] + + +@app.on_event("startup") +def startup(): + initialize_database() + start_scheduler() + + +@app.on_event("shutdown") +def shutdown(): + stop_scheduler() + + +class AllowanceRequest(BaseModel): + weekday: int + seconds: int + + +class WindowRequest(BaseModel): + weekday: int + start_minute: int + end_minute: int + + +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" + } + + +@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() + + return [ + { + "id": row["id"], + "username": row["username"], + "enabled": bool(row["enabled"]), + "locked": is_locked(row["username"]) + } + for row in rows + ] + + +@app.post("/api/users/{user_id}/lock") +def manually_lock(user_id: int): + row = get_user(user_id) + + if row is None: + raise HTTPException( + status_code=404, + detail="User not found" + ) + + lock_user(row["username"]) + + return { + "username": row["username"], + "locked": True + } + + +@app.post("/api/users/{user_id}/unlock") +def manually_unlock(user_id: int): + row = get_user(user_id) + + if row is None: + raise HTTPException( + status_code=404, + detail="User not found" + ) + + unlock_user(row["username"]) + + return { + "username": row["username"], + "locked": False + } + + +@app.post("/api/users/{user_id}/terminate") +def manually_terminate(user_id: int): + row = get_user(user_id) + + if row is None: + raise HTTPException( + status_code=404, + detail="User not found" + ) + + terminate_user(row["username"]) + + return { + "username": row["username"], + "terminated": True + } + + +@app.post("/api/users/{user_id}/allowance") +def set_allowance( + user_id: int, + request: AllowanceRequest +): + 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 + } + + +@app.post("/api/users/{user_id}/windows") +def add_window( + user_id: int, + request: WindowRequest +): + 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" + } + + +@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" + } + + +@app.post("/api/users/{user_id}/grant") +def grant_time( + user_id: int, + request: GrantRequest +): + 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 + } + + +@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() + + return templates.TemplateResponse( + request=request, + name="index.html", + context={ + "users": users + } + ) + + +@app.get("/admin/users/{user_id}") +def admin_user_page( + request: Request, + user_id: int +): + user = get_user(user_id) + + if user is None: + 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 + } + + return templates.TemplateResponse( + request=request, + name="user.html", + context={ + "user": user, + "locked": is_locked(user["username"]), + "weekdays": WEEKDAYS, + "allowances": allowance_map, + "windows": windows, + "grants": grants, + } + ) + + +@app.post("/admin/users/{user_id}/allowance") +def admin_set_allowance( + user_id: int, + weekday: int = Form(...), + hours: int = Form(...), + minutes: int = Form(...), +): + if get_user(user_id) is None: + raise HTTPException( + status_code=404, + detail="User not found" + ) + + 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 + ) + + +@app.post("/admin/users/{user_id}/window") +def admin_add_window( + user_id: int, + weekday: int = Form(...), + start_time: str = Form(...), + end_time: str = Form(...), +): + if get_user(user_id) is None: + 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(":") + ) + except ValueError: + raise HTTPException( + status_code=400, + detail="Invalid time format" + ) + + start_total = ( + start_hour * 60 + + start_minute + ) + + end_total = ( + end_hour * 60 + + end_minute + ) + + 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 + ) + + +@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() + + if row 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 + ) + + +@app.post("/admin/users/{user_id}/grant") +def admin_grant_time( + user_id: int, + hours: int = Form(...), + minutes: int = Form(...), +): + if get_user(user_id) is None: + 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 + ) + + if 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, + seconds, + 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 +): + row = get_user(user_id) + + if row is None: + raise HTTPException( + status_code=404, + detail="User not found" + ) + + lock_user(row["username"]) + + return RedirectResponse( + f"/admin/users/{user_id}", + status_code=303 + ) + + +@app.post("/admin/users/{user_id}/unlock") +def admin_unlock_user( + user_id: int +): + row = get_user(user_id) + + if row is None: + raise HTTPException( + status_code=404, + detail="User not found" + ) + + unlock_user(row["username"]) + + return RedirectResponse( + f"/admin/users/{user_id}", + status_code=303 + ) + + +@app.post("/admin/users/{user_id}/terminate") +def admin_terminate_user( + user_id: int +): + row = get_user(user_id) + + if row is None: + raise HTTPException( + status_code=404, + detail="User not found" + ) + + terminate_user(row["username"]) + + 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,) + ) + + return RedirectResponse( + "/admin", + status_code=303 + ) + + +@app.post("/admin/users") +def admin_add_user( + username: str = Form(...) +): + username = username.strip() + + if not linux_user_exists(username): + raise HTTPException( + status_code=400, + detail="Linux user does not exist" + ) + + with get_db() as db: + + 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 + ) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..576ca63 --- /dev/null +++ b/app/models.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class User: + id: int + username: str + enabled: bool + + +@dataclass +class DailyAllowance: + user_id: int + weekday: int + allowance_seconds: int + + +@dataclass +class AccessWindow: + id: int + user_id: int + weekday: int + start_minute: int + end_minute: int + + +@dataclass +class TemporaryGrant: + id: int + user_id: int + seconds: int + expires_at: Optional[str] + consumed: bool diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..8c7876a --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,183 @@ +import threading +import time +from datetime import datetime + +from .enforcement import ( + enforce_all_users, + record_usage, + get_user_policy, + consume_grant_seconds, +) + + +CHECK_INTERVAL = 5 + + +class Scheduler: + + def __init__( + self, + interval: int = CHECK_INTERVAL, + ): + self.interval = interval + + self._thread = None + self._stop_event = threading.Event() + + self._last_usage_update = {} + + def start(self): + + if ( + self._thread is not None + and self._thread.is_alive() + ): + return + + self._stop_event.clear() + + self._thread = threading.Thread( + target=self._run, + name="parental-control-scheduler", + daemon=True, + ) + + self._thread.start() + + def stop(self): + + self._stop_event.set() + + if self._thread is not None: + self._thread.join( + timeout=self.interval + 2 + ) + + def _run(self): + + # Evaluate immediately when the + # application starts. + self._tick() + + while not self._stop_event.wait( + self.interval + ): + self._tick() + + def _tick(self): + + now = time.monotonic() + + results = enforce_all_users() + + for result in results: + + user_id = result["user_id"] + + if not result["logged_in"]: + self._last_usage_update.pop( + user_id, + None, + ) + continue + + if not result["allowed"]: + self._last_usage_update.pop( + user_id, + None, + ) + continue + + previous = ( + self._last_usage_update.get( + user_id + ) + ) + + self._last_usage_update[user_id] = now + + if previous is None: + continue + + elapsed = int( + now - previous + ) + + if elapsed <= 0: + continue + + self._record_allowed_usage( + user_id, + elapsed, + ) + + def _record_allowed_usage( + self, + user_id: int, + seconds: int, + ): + + if seconds <= 0: + return + + weekday = datetime.now().weekday() + + ( + allowance_seconds, + usage_seconds, + windows, + grant_seconds, + ) = get_user_policy( + user_id, + weekday, + ) + + allowance_remaining = max( + 0, + allowance_seconds + - usage_seconds, + ) + + normal_usage = min( + seconds, + allowance_remaining, + ) + + grant_usage = ( + seconds + - normal_usage + ) + + if grant_usage > grant_seconds: + grant_usage = grant_seconds + + total_usage = ( + normal_usage + + grant_usage + ) + + if total_usage <= 0: + return + + record_usage( + user_id, + total_usage, + ) + + if grant_usage > 0: + + consume_grant_seconds( + user_id, + grant_usage, + ) + + +scheduler = Scheduler() + + +def start_scheduler(): + scheduler.start() + + +def stop_scheduler(): + scheduler.stop() diff --git a/app/users.py b/app/users.py new file mode 100644 index 0000000..bbb77b2 --- /dev/null +++ b/app/users.py @@ -0,0 +1,59 @@ +import pwd +import subprocess + + +def linux_user_exists(username: str) -> bool: + try: + pwd.getpwnam(username) + return True + except KeyError: + return False + + +def get_uid(username: str) -> int: + return pwd.getpwnam(username).pw_uid + + +def is_locked(username: str) -> bool: + result = subprocess.run( + ["passwd", "-S", username], + capture_output=True, + text=True, + check=False, + ) + + if result.returncode != 0: + return False + + parts = result.stdout.split() + + if len(parts) < 2: + return False + + return parts[1] == "L" + + +def lock_user(username: str): + subprocess.run( + ["loginctl", "terminate-user", username], + check=False, + ) + + subprocess.run( + ["passwd", "-l", username], + check=True, + ) + + +def unlock_user(username: str): + subprocess.run( + ["passwd", "-u", username], + check=True, + ) + + +def terminate_user(username: str): + subprocess.run( + ["loginctl", "terminate-user", username], + check=False, + ) diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..9194c89 --- /dev/null +++ b/install.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +set -e + +if [ "$EUID" -ne 0 ]; then + echo "Please run with sudo" + exit 1 +fi + + +INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "Installing from:" +echo "$INSTALL_DIR" + + +echo "[1/5] Installing dependencies" + +pacman -S --needed --noconfirm python python-pip + + +echo "[2/5] Creating virtual environment" + +if [ ! -d "$INSTALL_DIR/.venv" ]; then + python -m venv "$INSTALL_DIR/.venv" +fi + + +echo "[3/5] Installing Python packages" + +"$INSTALL_DIR/.venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" + + +echo "[4/5] Installing systemd service" + +sed \ +"s|%INSTALL_DIR%|$INSTALL_DIR|g" \ +"$INSTALL_DIR/parental-control.service" \ +> /etc/systemd/system/parental-control.service + + +systemctl daemon-reload + +systemctl enable parental-control.service + +systemctl restart parental-control.service + + +echo +echo "==================================" +echo "Parental Control installed" +echo +echo "Admin panel:" +echo "http://127.0.0.1:8765/admin" +echo +echo "Service status:" +echo "systemctl status parental-control" +echo "==================================" diff --git a/parental-control.service b/parental-control.service new file mode 100644 index 0000000..59f5791 --- /dev/null +++ b/parental-control.service @@ -0,0 +1,22 @@ +[Unit] +Description=Linux Parental Control +After=network.target + +[Service] +Type=simple + +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" + +ExecStart=%INSTALL_DIR%/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8765 + +Restart=always +RestartSec=5 + +User=root +Group=root + +[Install] +WantedBy=multi-user.target diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..26a87d2 --- /dev/null +++ b/readme.md @@ -0,0 +1,52 @@ +# 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://github.com/Alsantek-me/linux-user-timer.git +``` + +# 2. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..335467c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.15.1 +click==8.5.0 +fastapi==0.141.1 +h11==0.16.0 +idna==3.19 +Jinja2==3.1.6 +MarkupSafe==3.0.3 +pydantic==2.13.5 +pydantic_core==2.46.5 +python-multipart==0.0.32 +starlette==1.6.0 +typing-inspection==0.4.4 +typing_extensions==4.16.0 +uvicorn==0.53.0 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..959863c --- /dev/null +++ b/templates/index.html @@ -0,0 +1,435 @@ + + + + + + + + + + Parental Control + + + + + + + + + +
+ +

+ Parental Control +

+ +
+ + +
+ + +
+ +

+ Users +

+ +
+ + + {% if users %} + + + {% for user in users %} + + +
+ + +
+ + +
+ +
+ + {{ user.username }} + +
+ + +
+ + Linux user ID: + {{ user.id }} + +
+ +
+ + + {% if user.enabled %} + + + + Enabled + + + + {% else %} + + + + Disabled + + + + {% endif %} + + +
+ + +
+ + + + + + + + + +
+ + + +
+ + +
+ + +
+ + + {% endfor %} + + + {% else %} + + +
+ + No users configured yet. + +
+ + + {% endif %} + + + +
+ + +

+ Add User +

+ + +

+ + Add an existing Linux account to + parental control. + +

+ + +
+ + + + + + + + +
+ + +
+ + +
+ + + + + diff --git a/templates/user.html b/templates/user.html new file mode 100644 index 0000000..ba162ce --- /dev/null +++ b/templates/user.html @@ -0,0 +1,859 @@ + + + + + + + + + + Manage {{ user.username }} + + + + + + + +
+ +

+ Parental Control +

+ +
+ +
+ + + ← Back to Users + + + + + +
+ +
+ +
+ +
+ {{ user.username }} +
+ +
+ Linux user ID: + {{ user.id }} +
+ +
+ + + {% if locked %} + + + Locked + + + {% else %} + + + Unlocked + + + {% endif %} + +
+ + +
+ + {% if locked %} + +
+ + + +
+ + {% else %} + +
+ + + +
+ + {% endif %} + + +
+ + + +
+ +
+ +
+ + + + +
+ +

+ Daily Allowance +

+ +

+ Maximum amount of usage allowed on each day. +

+ + + + + + + + + + + + + + + + + + + + + + + {% for weekday, name in weekdays %} + + {% set total_seconds = allowances.get( + weekday, + 0 + ) %} + + {% set total_minutes = total_seconds // 60 %} + + {% set hours = total_minutes // 60 %} + + {% set minutes = total_minutes % 60 %} + + + + + + + + + + + + {% endfor %} + + + +
+ Day + + Hours + + Minutes + + Save +
+ + {{ name }} + + + +
+ + + + + + + hours + + + + + + minutes + + +
+ + + + + +
+ +
+ + + + +
+ +

+ Access Windows +

+ +

+ Define when this user is allowed to access + the computer. Multiple windows can be created + for the same day. +

+ + + {% for weekday, name in weekdays %} + + {% set day_windows = [] %} + + {% for window in windows %} + + {% if window.weekday == weekday %} + + {% set _ = day_windows.append(window) %} + + {% endif %} + + {% endfor %} + + +

+ {{ name }} +

+ + + {% for window in day_windows %} + + {% set start_hour = + window.start_minute // 60 + %} + + {% set start_min = + window.start_minute % 60 + %} + + {% set end_hour = + window.end_minute // 60 + %} + + {% set end_min = + window.end_minute % 60 + %} + + +
+ +
+ + + {{ "%02d:%02d" | format( + start_hour, + start_min + ) }} + + – + + {{ "%02d:%02d" | format( + end_hour, + end_min + ) }} + + +
+ + +
+ + + +
+ +
+ + {% endfor %} + + +
+ + + + + + + + + +
+ + {% endfor %} + +
+ + + + +
+ +

+ Give Temporary Time +

+ +

+ Add extra time that can be used outside the + normal allowance. +

+ + +
+ + + + + hours + + + + + + minutes + + + + +
+ +
+ + + + +
+ +

+ Temporary Grants +

+ + {% if grants %} + + + + + + + + + + + + + + + + + + + + {% for grant in grants %} + + + + + + + + + + + + {% endfor %} + + + +
+ Time + + Created + + Status +
+ + {% set grant_minutes = + grant.seconds // 60 + %} + + {% set grant_hours = + grant_minutes // 60 + %} + + {% set grant_remaining = + grant_minutes % 60 + %} + + {{ grant_hours }}h + {{ grant_remaining }}m + + + {{ grant.created_at }} + + + {% if grant.consumed %} + + Used + + {% else %} + + Available + + {% endif %} + +
+ + {% else %} + +

+ No temporary grants yet. +

+ + {% endif %} + +
+ + + + +
+ +

+ Danger Zone +

+ +

+ Removing this user deletes their parental + control configuration from this application. + It does not delete the Linux account. +

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