From bd973a641c422b7137c5f1657c9524f194fa12ff Mon Sep 17 00:00:00 2001 From: alex Date: Sat, 19 Sep 2026 12:38:49 +0500 Subject: [PATCH] added graps --- .gitignore | 29 +- README.md | 157 ++--- app/enforcement.py | 16 +- app/main.py | 62 +- app/scheduler.py | 143 +---- app/storage.py | 115 ++-- app/users.py | 114 ++++ data/config.yaml | 3 +- data/state.json | 11 + install.sh | 29 +- linux-user-timer-main/.gitignore | 5 + parental-control.service | 2 +- requirements.txt | 1 + templates/index.html | 457 ++------------ templates/login.html | 81 +-- templates/user.html | 1018 +++++++----------------------- 16 files changed, 735 insertions(+), 1508 deletions(-) create mode 100644 data/state.json create mode 100644 linux-user-timer-main/.gitignore diff --git a/.gitignore b/.gitignore index 761276a..e79037d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,31 +1,36 @@ # Python __pycache__/ *.py[cod] -*$py.class +*.pyo -# Virtual environments +# Virtual environment .venv/ venv/ env/ -# Environment / secrets -.env -.env.* -!.env.example +# Python tooling +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ -# Runtime state +# Runtime/application state data/state.json +# Local configuration +data/config.yaml + # Logs *.log logs/ -# IDE / editors +# Environment/secrets +.env +.env.* +*.secret +*.key + +# Editor/OS files .vscode/ .idea/ *.swp -*.swo - -# OS files .DS_Store -Thumbs.db diff --git a/README.md b/README.md index e54e1ab..f017cd0 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,108 @@ # 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 +A Linux parental-control system for managing Linux user access, daily time allowances, access windows, temporary grants, usage history, and automatic session enforcement. ## Storage SQLite has been removed. -The application now uses two files under `data/`: +The application 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. +- `config.yaml` — users, daily allowances, access windows, and PAM configuration. +- `state.json` — daily usage, temporary grants, event history, and ID counters. 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**. +The administration web interface uses Linux PAM for password authentication and a Linux group for authorization. -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: +The default PAM settings are: ```yaml auth: pam_service: login + pam_group: pam ``` -If your distribution uses a different PAM service, change `pam_service` in `data/config.yaml`. +The installer creates the `pam` group and adds `root` to it. Any Linux account that successfully authenticates through the configured PAM service must also belong to this group to access `/admin`. -### Restricting who can use the web panel +To grant another administrator access: -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 +```bash +sudo usermod -aG pam username ``` -Do **not** add a user controlled by the parental-control enforcement system to `admin_users`, because account locking is performed with `passwd`. +To remove access: -The web session is signed with a randomly generated secret stored in: - -```text -/etc/parental-control/session-secret -/etc/parental-control/session-secret.env +```bash +sudo gpasswd -d username pam ``` -## Features +The application re-checks group membership on each request, so a removed member cannot continue using an existing session. -- 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 +## Adding parental-control users + +The web interface provides a drop-down containing eligible regular Linux accounts that are not already configured. + +The selector excludes: + +- `root` / UID 0 accounts. +- System accounts below the configured `UID_MIN`. +- Accounts in the standard `wheel`, `sudo`, or `root` groups. + +This is intended to prevent the administrator account from accidentally being selected for parental enforcement. + +## Policy behavior + +Daily allowance and access windows are independent configuration items and may be created in any order. + +For a normal daily allowance: + +- If a day has no access window, there is no time-of-day restriction for that day. +- If a day has one or more access windows, normal allowance access is permitted only while the current time is inside at least one configured window. +- The daily allowance still limits the total normal usage for that day. +- A temporary grant overrides the normal allowance and access-window restriction. + +The scheduler re-reads the current YAML/JSON state every enforcement cycle, so changing an allowance before or after a window produces the same final policy. + +## Usage graph + +Each managed user has a 14-day usage view showing: + +- Total usage over the last 14 calendar days. +- Usage today. +- Remaining allowance today. +- A daily graph comparing recorded usage with the configured daily allowance. + +Usage data is stored in `state.json` and survives service restarts. ## Requirements The application targets Linux systems using `systemd`. -You need: +You need Linux, Python 3, Python virtual-environment support, pip, systemd, PAM, `sudo`, `passwd`, `loginctl`, and the standard user/group management commands. -- 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. +The enforcement service runs as `root` 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: +The Arch Linux installer intentionally runs: -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. +```bash +pacman -S --needed python python-pip +``` + +It does **not** run `pacman -Syu`, so installing this application does not trigger a full Arch system upgrade. + +The installer also creates the `pam` group, adds `root`, creates the Python virtual environment, installs the application dependencies, removes the old SQLite database if present, creates the systemd service, and starts it. ## Service commands @@ -133,6 +112,12 @@ sudo systemctl restart parental-control sudo journalctl -u parental-control -f ``` +The administration panel is available at: + +```text +http://127.0.0.1:8765/admin +``` + ## Reverse proxy / HTTPS The application listens on: @@ -141,16 +126,14 @@ The application listens on: 127.0.0.1:8765 ``` -Put it behind your existing Nginx/Apache/reverse proxy if you want remote access. +Put it behind your existing Nginx/Apache/reverse proxy if remote access is required. -When HTTPS is provided directly to users, set: +For HTTPS-only session cookies, set this in the systemd service: ```ini Environment="PARENTAL_CONTROL_HTTPS_ONLY=1" ``` -in the systemd service. This marks the session cookie as HTTPS-only. +## Security note -## 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. +This application controls Linux accounts and runs as root. Do not expose port `8765` directly to an untrusted network. Prefer localhost binding with an HTTPS reverse proxy and appropriate firewall rules. diff --git a/app/enforcement.py b/app/enforcement.py index 3ee275b..ef0efd8 100644 --- a/app/enforcement.py +++ b/app/enforcement.py @@ -3,9 +3,6 @@ import subprocess from .storage import ( get_user_policy, - get_remaining_grant_seconds, - consume_grant_seconds, - record_usage, record_event, list_users, ) @@ -40,6 +37,7 @@ def current_time(): def is_inside_window(windows, minute: int) -> bool: + # No configured windows means that no time-of-day restriction exists. if not windows: return True @@ -57,14 +55,17 @@ def evaluate_user(user_id: int, username: str): usage_seconds, windows, grant_seconds, - ) = get_user_policy(user_id, weekday) + ) = get_user_policy(user_id, weekday, now.date()) 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 + # A user's normal allowance is always constrained by the configured + # access window (when one exists). A temporary grant bypasses the window + # and normal allowance by design. + normal_access_available = allowance_remaining > 0 + allowed_by_schedule = inside_window and normal_access_available allowed_by_grant = grant_seconds > 0 should_allow = allowed_by_schedule or allowed_by_grant @@ -104,12 +105,13 @@ def evaluate_user(user_id: int, username: str): "weekday": weekday, "minute": minute, "inside_window": inside_window, + "has_configured_windows": bool(windows), "logged_in": logged_in, "allowance_seconds": allowance_seconds, "usage_seconds": usage_seconds, "allowance_remaining": allowance_remaining, "grant_seconds": grant_seconds, - "total_remaining": total_remaining, + "normal_access_available": normal_access_available, "allowed": should_allow, } diff --git a/app/main.py b/app/main.py index 20d28a1..2f619fc 100644 --- a/app/main.py +++ b/app/main.py @@ -1,3 +1,4 @@ +import os import secrets from pathlib import Path @@ -23,9 +24,12 @@ from .storage import ( add_grant, list_grants, is_admin_allowed, + get_usage_history, ) from .users import ( linux_user_exists, + is_non_root_user, + list_available_users, lock_user, unlock_user, terminate_user, @@ -34,13 +38,13 @@ from .users import ( BASE_DIR = Path(__file__).resolve().parent.parent -SESSION_SECRET = __import__("os").environ.get( +SESSION_SECRET = os.environ.get( "PARENTAL_CONTROL_SESSION_SECRET" ) or secrets.token_urlsafe(32) app = FastAPI( title="Parental Control", - version="0.2.0", + version="0.3.0", ) app.add_middleware( @@ -49,9 +53,7 @@ app.add_middleware( 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", + https_only=os.environ.get("PARENTAL_CONTROL_HTTPS_ONLY", "0") == "1", ) templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) @@ -83,6 +85,8 @@ def current_user(request: Request): if not username: return None + # Re-check the Linux PAM group on every request so removing an account + # from the admin group takes effect without waiting for the session TTL. if not is_admin_allowed(username): request.session.clear() return None @@ -100,7 +104,6 @@ def require_web_auth(request: Request): f"/login?next={next_path}", status_code=303, ) - return None @@ -144,16 +147,19 @@ class GrantRequest(BaseModel): def root(): return { "application": "Parental Control", - "version": "0.2.0", + "version": "0.3.0", "status": "running", - "authentication": "PAM", + "authentication": "PAM + pam Linux group", } @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 RedirectResponse( + next if next.startswith("/") and not next.startswith("//") else "/admin", + status_code=303, + ) return templates.TemplateResponse( request=request, @@ -161,6 +167,7 @@ def login_page(request: Request, next: str = "/admin"): context={ "next": next if next.startswith("/") else "/admin", "error": None, + "pam_group": get_config()["auth"].get("pam_group", "pam"), }, ) @@ -191,6 +198,7 @@ def login( context={ "next": safe_next, "error": "Invalid Linux username or password.", + "pam_group": get_config()["auth"].get("pam_group", "pam"), }, status_code=401, ) @@ -201,7 +209,8 @@ def login( name="login.html", context={ "next": safe_next, - "error": "This Linux account is not allowed to access the administration panel.", + "error": "Your Linux account is authenticated, but it is not a member of the PAM administration group.", + "pam_group": get_config()["auth"].get("pam_group", "pam"), }, status_code=403, ) @@ -236,6 +245,14 @@ def list_users_api(request: Request): ] +@app.get("/api/users/{user_id}/usage") +def user_usage_api(request: Request, user_id: int, days: int = 14): + require_api_auth(request) + if get_user(user_id) is None: + raise HTTPException(status_code=404, detail="User not found") + return get_usage_history(user_id, days) + + @app.post("/api/users/{user_id}/lock") def manually_lock(user_id: int, request: Request): require_api_auth(request) @@ -318,13 +335,21 @@ def admin_page(request: Request): if redirect: return redirect + configured = {user["username"] for user in list_users()} + available_users = [ + user for user in list_available_users() + if user["username"] not in configured + ] + return templates.TemplateResponse( request=request, name="index.html", context={ "users": list_users(), + "available_users": available_users, "username": current_user(request), "csrf_token": csrf_token(request), + "pam_group": get_config()["auth"].get("pam_group", "pam"), }, ) @@ -349,6 +374,12 @@ def admin_user_page(request: Request, user_id: int): key=lambda item: (int(item["weekday"]), int(item["start_minute"])), ) + usage_history = get_usage_history(user_id, 14) + total_used = sum(item["used_seconds"] for item in usage_history) + total_allowance = sum(item["allowance_seconds"] for item in usage_history) + today_used = usage_history[-1]["used_seconds"] if usage_history else 0 + today_allowance = usage_history[-1]["allowance_seconds"] if usage_history else 0 + return templates.TemplateResponse( request=request, name="user.html", @@ -359,6 +390,11 @@ def admin_user_page(request: Request, user_id: int): "allowances": allowances, "windows": windows, "grants": list_grants(user_id), + "usage_history": usage_history, + "total_used": total_used, + "total_allowance": total_allowance, + "today_used": today_used, + "today_allowance": today_allowance, "csrf_token": csrf_token(request), "username": current_user(request), }, @@ -531,6 +567,12 @@ def admin_add_user( if not linux_user_exists(username): raise HTTPException(status_code=400, detail="Linux user does not exist") + if not is_non_root_user(username): + raise HTTPException( + status_code=400, + detail="Only regular non-root Linux users can be added to parental control.", + ) + if get_user_by_username(username) is not None: raise HTTPException(status_code=400, detail="User is already configured") diff --git a/app/scheduler.py b/app/scheduler.py index bfdf1b5..a4845e3 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -1,156 +1,86 @@ import threading import time -from datetime import datetime - +from datetime import date from .enforcement import enforce_all_users -from .storage import record_usage, get_user_policy, consume_grant_seconds +from .storage import get_user_policy, record_usage, consume_grant_seconds CHECK_INTERVAL = 5 class Scheduler: - - def __init__( - self, - interval: int = CHECK_INTERVAL, - ): + 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() - ): + 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 - ) + 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 - ): + 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"] + previous = self._last_usage_update.get(user_id) - if not result["logged_in"]: - self._last_usage_update.pop( - user_id, - None, - ) - continue + if previous is not None: + elapsed = max(0, int(now - previous["monotonic"])) + if elapsed > 0 and previous["allowed"] and previous["logged_in"]: + self._record_allowed_usage( + user_id, + elapsed, + previous["weekday"], + previous["date"], + ) - 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 result["logged_in"] and result["allowed"]: + self._last_usage_update[user_id] = { + "monotonic": now, + "allowed": True, + "logged_in": True, + "weekday": result["weekday"], + "date": result["timestamp"][:10], + } + else: + self._last_usage_update.pop(user_id, None) + def _record_allowed_usage(self, user_id: int, seconds: int, weekday: int, usage_date: str): if seconds <= 0: return - weekday = datetime.now().weekday() - ( allowance_seconds, usage_seconds, - windows, + _windows, grant_seconds, - ) = get_user_policy( - user_id, - weekday, - ) + ) = 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 - ) + allowance_remaining = max(0, allowance_seconds - usage_seconds) + normal_usage = min(seconds, allowance_remaining) + grant_usage = min(max(0, seconds - normal_usage), grant_seconds) + total_usage = normal_usage + grant_usage if total_usage <= 0: return @@ -158,14 +88,11 @@ class Scheduler: record_usage( user_id, total_usage, + date.fromisoformat(usage_date), ) if grant_usage > 0: - - consume_grant_seconds( - user_id, - grant_usage, - ) + consume_grant_seconds(user_id, grant_usage) scheduler = Scheduler() diff --git a/app/storage.py b/app/storage.py index da29f39..f3403cc 100644 --- a/app/storage.py +++ b/app/storage.py @@ -1,10 +1,9 @@ import json import os import tempfile -from contextlib import contextmanager -from datetime import datetime -from pathlib import Path +from datetime import datetime, date, timedelta from threading import RLock +from pathlib import Path import yaml @@ -19,7 +18,7 @@ DEFAULT_CONFIG = { "version": 1, "auth": { "pam_service": "login", - "admin_users": [], + "pam_group": "pam", }, "users": [], } @@ -68,9 +67,13 @@ def _load_yaml(): data.setdefault("version", 1) data.setdefault("auth", {}) + if not isinstance(data["auth"], dict): + raise ValueError("config.yaml auth must be an object") data["auth"].setdefault("pam_service", "login") - data["auth"].setdefault("admin_users", []) + data["auth"].setdefault("pam_group", "pam") data.setdefault("users", []) + if not isinstance(data["users"], list): + raise ValueError("config.yaml users must be a list") return data @@ -124,8 +127,6 @@ def initialize_storage(): 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) @@ -141,14 +142,14 @@ 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 get_pam_group(): + return get_config()["auth"].get("pam_group", "pam") def is_admin_allowed(username: str) -> bool: - allowed = admin_users() - return not allowed or username in allowed + """Keep authorization in users.py so PAM group membership is system-backed.""" + from .users import user_in_group + return user_in_group(username, get_pam_group()) def _find_user(config, user_id): @@ -256,7 +257,7 @@ def set_allowance(user_id: int, weekday: int, seconds: int): raise KeyError("User not found") user.setdefault("allowances", {}) - user["allowances"][str(weekday)] = int(seconds) + user["allowances"][str(weekday)] = max(0, int(seconds)) _save_yaml(config) @@ -298,7 +299,26 @@ def delete_window(window_id: int): return int(owner["id"]) -def get_user_policy(user_id: int, weekday: int): +def _active_grant_seconds(state, user_id: int, now_iso: str) -> int: + 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.get("remaining_seconds", 0)) > 0 + and ( + grant.get("expires_at") is None + or grant["expires_at"] > now_iso + ) + ) + + +def get_user_policy(user_id: int, weekday: int, on_date: date | None = None): + """Return the complete policy for a specific weekday/date. + + Configuration is always read from the current files, so allowance and + access-window changes are order-independent. + """ with _lock: config = _load_yaml() user = _find_user(config, user_id) @@ -318,22 +338,14 @@ def get_user_policy(user_id: int, weekday: int): ) state = _load_json() - today = datetime.now().date().isoformat() + target_date = on_date or datetime.now().date() + today = target_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 - ) - ) + grant_seconds = _active_grant_seconds(state, user_id, now) return allowance_seconds, usage_seconds, windows, grant_seconds @@ -341,17 +353,10 @@ def get_user_policy(user_id: int, weekday: int): 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 - ) + return _active_grant_seconds( + state, + user_id, + datetime.now().isoformat(), ) @@ -407,18 +412,49 @@ def consume_grant_seconds(user_id: int, seconds: int): _save_json(state) -def record_usage(user_id: int, seconds: int): +def record_usage(user_id: int, seconds: int, usage_date: date | None = None): if seconds <= 0: return with _lock: state = _load_json() - today = datetime.now().date().isoformat() - key = f"{int(user_id)}:{today}" + target_date = usage_date or datetime.now().date() + key = f"{int(user_id)}:{target_date.isoformat()}" state["usage"][key] = int(state["usage"].get(key, 0)) + int(seconds) _save_json(state) +def get_usage_history(user_id: int, days: int = 14): + days = max(1, min(int(days), 90)) + + with _lock: + config = _load_yaml() + state = _load_json() + user = _find_user(config, user_id) + if user is None: + return [] + + today = datetime.now().date() + history = [] + + for offset in range(days - 1, -1, -1): + day = today - timedelta(days=offset) + weekday = day.weekday() + allowance = int( + user.get("allowances", {}).get(str(weekday), 0) + ) + key = f"{int(user_id)}:{day.isoformat()}" + used = int(state["usage"].get(key, 0)) + history.append({ + "date": day.isoformat(), + "weekday": weekday, + "used_seconds": used, + "allowance_seconds": allowance, + }) + + return history + + def list_grants(user_id: int, limit: int = 20): with _lock: state = _load_json() @@ -440,6 +476,5 @@ def record_event(user_id, event_type: str, details: str = ""): "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/app/users.py b/app/users.py index bbb77b2..d275c67 100644 --- a/app/users.py +++ b/app/users.py @@ -1,5 +1,7 @@ +import grp import pwd import subprocess +from typing import List, Dict def linux_user_exists(username: str) -> bool: @@ -14,6 +16,118 @@ def get_uid(username: str) -> int: return pwd.getpwnam(username).pw_uid +def _login_def_value(name: str, default: int) -> int: + """Read an integer value from /etc/login.defs.""" + try: + with open("/etc/login.defs", "r", encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if len(parts) >= 2 and parts[0] == name: + value = int(parts[1]) + if value > 0: + return value + except (OSError, ValueError): + pass + return default + + +def _uid_min() -> int: + return _login_def_value("UID_MIN", 1000) + + +def _uid_max() -> int: + return _login_def_value("UID_MAX", 60000) + + +def _is_interactive_shell(shell: str) -> bool: + """Exclude service accounts that cannot be used for interactive logins.""" + shell = (shell or "").strip().lower() + if not shell: + return False + return not shell.endswith(("/nologin", "/false")) + + +def _supplementary_groups(username: str) -> set[str]: + groups = set() + try: + user = pwd.getpwnam(username) + except KeyError: + return groups + + try: + groups.add(grp.getgrgid(user.pw_gid).gr_name) + except KeyError: + pass + + for group in grp.getgrall(): + if username in group.gr_mem: + groups.add(group.gr_name) + + return groups + + +def has_root_privileges(username: str) -> bool: + """Best-effort detection for accounts with ordinary root-style group access.""" + try: + user = pwd.getpwnam(username) + except KeyError: + return False + + if user.pw_uid == 0: + return True + + groups = _supplementary_groups(username) + return bool(groups.intersection({"root", "wheel", "sudo"})) + + +def is_non_root_user(username: str) -> bool: + """Return True for regular non-root accounts suitable for parental control.""" + try: + user = pwd.getpwnam(username) + except KeyError: + return False + + if user.pw_uid < _uid_min() or user.pw_uid > _uid_max(): + return False + + if username in {"nobody", "nfsnobody"}: + return False + + if not _is_interactive_shell(user.pw_shell): + return False + + return not has_root_privileges(username) + + +def list_available_users() -> List[Dict[str, str]]: + """Return regular interactive users that can be selected for parental control.""" + users = [] + + for user in pwd.getpwall(): + if not is_non_root_user(user.pw_name): + continue + + users.append({"username": user.pw_name}) + + return sorted(users, key=lambda item: item["username"].lower()) + + +def user_in_group(username: str, group_name: str) -> bool: + try: + group = grp.getgrnam(group_name) + user = pwd.getpwnam(username) + except KeyError: + return False + + return ( + username in group.gr_mem + or user.pw_gid == group.gr_gid + ) + + def is_locked(username: str) -> bool: result = subprocess.run( ["passwd", "-S", username], diff --git a/data/config.yaml b/data/config.yaml index 18ec808..2db9390 100644 --- a/data/config.yaml +++ b/data/config.yaml @@ -1,6 +1,5 @@ version: 1 auth: pam_service: login - admin_users: - - root + pam_group: pam users: [] diff --git a/data/state.json b/data/state.json new file mode 100644 index 0000000..4d68239 --- /dev/null +++ b/data/state.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "next_ids": { + "user": 3, + "window": 2, + "grant": 1 + }, + "usage": {}, + "temporary_grants": [], + "events": [] +} diff --git a/install.sh b/install.sh index 9b431cd..8f80714 100755 --- a/install.sh +++ b/install.sh @@ -113,7 +113,9 @@ echo "[1/5] Installing dependencies" case "$PACKAGE_MANAGER" in pacman) - pacman -Syu --needed --noconfirm \ + # Do NOT use -Syu here. This installer must not upgrade the whole + # Arch system; it only installs the packages required by this app. + pacman -S --needed --noconfirm \ python \ python-pip ;; @@ -156,7 +158,6 @@ case "$PACKAGE_MANAGER" in esac - # ============================================================ # Verify Python # ============================================================ @@ -174,6 +175,27 @@ echo "Python:" echo +# ============================================================ +# Prepare PAM administration group +# ============================================================ + +echo "Preparing PAM administration group" + +if ! getent group pam >/dev/null 2>&1; then + echo "Creating system group: pam" + groupadd --system pam +fi + +if ! id -nG root | tr " " "\n" | grep -qx "pam"; then + echo "Adding root to group: pam" + usermod -aG pam root +fi + +echo "PAM group:" +getent group pam +echo + + # ============================================================ # Create virtual environment # ============================================================ @@ -194,9 +216,6 @@ fi echo echo "[3/5] Installing Python packages" -"$INSTALL_DIR/.venv/bin/python" -m pip install \ - --upgrade pip - "$INSTALL_DIR/.venv/bin/python" -m pip install \ -r "$INSTALL_DIR/requirements.txt" diff --git a/linux-user-timer-main/.gitignore b/linux-user-timer-main/.gitignore new file mode 100644 index 0000000..aee36bc --- /dev/null +++ b/linux-user-timer-main/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +data/state.json +data/*.db diff --git a/parental-control.service b/parental-control.service index 1c6007d..c7f2310 100644 --- a/parental-control.service +++ b/parental-control.service @@ -11,7 +11,7 @@ Environment="PATH=%INSTALL_DIR%/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/bi Environment="PARENTAL_CONTROL_ENFORCEMENT=1" EnvironmentFile=-/etc/parental-control/session-secret.env -ExecStart=%INSTALL_DIR%/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8765 +ExecStart=%INSTALL_DIR%/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8765 Restart=always RestartSec=5 diff --git a/requirements.txt b/requirements.txt index 4c972f8..512b095 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,4 +16,5 @@ starlette==1.6.0 typing-inspection==0.4.4 typing_extensions==4.16.0 uvicorn==0.53.0 +itsdangerous>=2.2.0 itsdangerous diff --git a/templates/index.html b/templates/index.html index f62b9d2..2414909 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,445 +1,108 @@ - - - - - + Parental Control - - - - - -
- -

- Parental Control -

- -
Signed in as {{ username }} +

Parental Control

+
+ Signed in as {{ username }}
-
-
-
- -
- -

- Users -

- +
+

Users

+
Configure Linux accounts controlled by this service.
+
- {% if users %} - - {% for user in users %} - -
- -
- -
- -
- - {{ user.username }} - -
- - -
- - Linux user ID: - {{ user.id }} - -
- +
{{ user.username }}
+
Application user ID: {{ user.id }}
- - {% if user.enabled %} - - - - Enabled - - - - {% else %} - - - - Disabled - - - + Enabled {% endif %} - -
-
- - - - - - - - - -
+ Manage + - - - +
- -
- -
- - {% endfor %} - - {% else %} - - -
- - No users configured yet. - -
- - +
No users configured yet.
{% endif %} - -
+

Add User

+

Select an existing regular Linux account. Root, system accounts, and accounts with standard sudo/wheel access are excluded.

- -

- Add User -

- - -

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

- - -
- - - - - - - - - + {% if available_users %} + + + +
- - + {% else %} +
No eligible non-root Linux users are currently available to add.
+ {% endif %}
- +
+ PAM administration group: {{ pam_group }} +
Only Linux users who authenticate with PAM and belong to this group can use the web panel.
+
- - - diff --git a/templates/login.html b/templates/login.html index 03f0ff8..9bade5f 100644 --- a/templates/login.html +++ b/templates/login.html @@ -3,80 +3,33 @@ - Sign in — Parental Control + Parental Control Login -
+

Parental Control

-

Sign in with a Linux account authenticated through PAM.

- - {% if error %} -
{{ error }}
- {% endif %} - +

Sign in with your Linux username and password. You must also be a member of the {{ pam_group }} Linux group.

+ {% if error %}
{{ error }}
{% endif %}
- - - - - - + + +
- -

The application does not store your Linux password.

-
+ diff --git a/templates/user.html b/templates/user.html index 4ee9990..01e00fe 100644 --- a/templates/user.html +++ b/templates/user.html @@ -1,875 +1,343 @@ - - - - - - Manage {{ user.username }} - - + + Manage {{ user.username }} - -
- -

- Parental Control -

- -
Signed in as {{ username }} +

Parental Control

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

Usage

+

Recorded usage for the last 14 calendar days. Normal usage is counted only while the account is inside its allowed policy and logged in.

+ +
+
+
Total used (14 days)
+
0m
+
+
+
Used today
+
0m
+
+
+
Remaining today
+
0m
+
+
+ +
+
+ Used + Daily allowance +
- - -
- -

- Daily Allowance -

- -

- Maximum amount of usage allowed on each day. -

- - +

Daily Allowance

+

Maximum normal usage allowed on each day. Access windows are a separate condition, so either section can be configured first.

- - - - - - - - - - - - - - - - - + - {% for weekday, name in weekdays %} - - {% set total_seconds = allowances.get( - weekday, - 0 - ) %} - + {% 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 -
DayHoursMinutesSave
- - {{ name }} - - {{ name }} - -
- - - - - - - - hours - - - - - - minutes - - + + + + + hours + + minutes
- - - - - -
-
- - -
- -

- Access Windows -

- -

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

- +

Access Windows

+

When at least one window exists for a day, normal allowance access is allowed only inside those windows. If no window exists for that day, there is no time-of-day restriction.

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

- {{ name }} -

- +

{{ 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 - %} - - + {% 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 - ) }} - - + {{ "%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 - - - - +

Give Temporary Time

+

Temporary time is an override and may be used outside the normal access window and daily allowance.

+ + + + hours + + minutes +
-
- - -
- -

- Temporary Grants -

- +

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 %} - -
- + + + + {% for grant in grants %} + + + + + + {% endfor %} + +
TimeCreatedStatus
+ {% 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. -

- +

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. -

- -
- - - - +

Danger Zone

+

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

+ + +
-
-
+