added graps

This commit is contained in:
2026-09-19 12:46:23 +05:00
parent b5593fa7f4
commit bd973a641c
16 changed files with 735 additions and 1508 deletions
+17 -12
View File
@@ -1,31 +1,36 @@
# Python # Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *.pyo
# Virtual environments # Virtual environment
.venv/ .venv/
venv/ venv/
env/ env/
# Environment / secrets # Python tooling
.env .pytest_cache/
.env.* .mypy_cache/
!.env.example .ruff_cache/
# Runtime state # Runtime/application state
data/state.json data/state.json
# Local configuration
data/config.yaml
# Logs # Logs
*.log *.log
logs/ logs/
# IDE / editors # Environment/secrets
.env
.env.*
*.secret
*.key
# Editor/OS files
.vscode/ .vscode/
.idea/ .idea/
*.swp *.swp
*.swo
# OS files
.DS_Store .DS_Store
Thumbs.db
+70 -87
View File
@@ -1,129 +1,108 @@
# Linux Parental Control # Linux Parental Control
A Linux parental-control system for managing Linux user access, daily time allowances, access windows, temporary grants, and automatic session enforcement. A Linux parental-control system for managing Linux user access, daily time allowances, access windows, temporary grants, usage history, and automatic session enforcement.
> **Status:** Early development
## Storage ## Storage
SQLite has been removed. 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. - `config.yaml` — users, daily allowances, access windows, and PAM configuration.
- `state.json` — daily usage, temporary grants, and a bounded event history. - `state.json` — daily usage, temporary grants, event history, and ID counters.
The application never creates or opens `data/parental-control.db`. 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 ## 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: The default PAM settings are:
```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 ```yaml
auth: auth:
pam_service: login 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. ```bash
sudo usermod -aG pam username
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`. To remove access:
The web session is signed with a randomly generated secret stored in: ```bash
sudo gpasswd -d username pam
```text
/etc/parental-control/session-secret
/etc/parental-control/session-secret.env
``` ```
## 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 ## Adding parental-control users
- Different allowances for each day of the week
- Multiple access windows per day The web interface provides a drop-down containing eligible regular Linux accounts that are not already configured.
- Temporary time grants
- Usage tracking The selector excludes:
- Automatic session termination
- Automatic account locking - `root` / UID 0 accounts.
- Automatic account unlocking - System accounts below the configured `UID_MIN`.
- Reboot-safe enforcement - Accounts in the standard `wheel`, `sudo`, or `root` groups.
- PAM-authenticated web administration
- YAML configuration This is intended to prevent the administrator account from accidentally being selected for parental enforcement.
- JSON runtime state
- systemd service support ## 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 ## Requirements
The application targets Linux systems using `systemd`. 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 The enforcement service runs as `root` because it manages other Linux users and their sessions.
- 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 ## Installation
Clone the repository:
```bash ```bash
git clone https://git.shihaam.dev/Alsan/linux-user-timer.git git clone https://git.shihaam.dev/Alsan/linux-user-timer.git
cd linux-user-timer cd linux-user-timer
```
Make the installer executable:
```bash
chmod +x install.sh chmod +x install.sh
```
Run:
```bash
sudo ./install.sh sudo ./install.sh
``` ```
The installer: The Arch Linux installer intentionally runs:
1. Installs Python dependencies including PyYAML and python-pam. ```bash
2. Creates the Python virtual environment. pacman -S --needed python python-pip
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. It does **not** run `pacman -Syu`, so installing this application does not trigger a full Arch system upgrade.
6. Installs and starts the systemd service.
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 ## Service commands
@@ -133,6 +112,12 @@ sudo systemctl restart parental-control
sudo journalctl -u parental-control -f sudo journalctl -u parental-control -f
``` ```
The administration panel is available at:
```text
http://127.0.0.1:8765/admin
```
## Reverse proxy / HTTPS ## Reverse proxy / HTTPS
The application listens on: The application listens on:
@@ -141,16 +126,14 @@ The application listens on:
127.0.0.1:8765 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 ```ini
Environment="PARENTAL_CONTROL_HTTPS_ONLY=1" 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 localhost binding with an HTTPS reverse proxy and appropriate firewall rules.
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.
+9 -7
View File
@@ -3,9 +3,6 @@ import subprocess
from .storage import ( from .storage import (
get_user_policy, get_user_policy,
get_remaining_grant_seconds,
consume_grant_seconds,
record_usage,
record_event, record_event,
list_users, list_users,
) )
@@ -40,6 +37,7 @@ def current_time():
def is_inside_window(windows, minute: int) -> bool: def is_inside_window(windows, minute: int) -> bool:
# No configured windows means that no time-of-day restriction exists.
if not windows: if not windows:
return True return True
@@ -57,14 +55,17 @@ def evaluate_user(user_id: int, username: str):
usage_seconds, usage_seconds,
windows, windows,
grant_seconds, grant_seconds,
) = get_user_policy(user_id, weekday) ) = get_user_policy(user_id, weekday, now.date())
inside_window = is_inside_window(windows, minute) inside_window = is_inside_window(windows, minute)
allowance_remaining = max(0, allowance_seconds - usage_seconds) allowance_remaining = max(0, allowance_seconds - usage_seconds)
total_remaining = allowance_remaining + grant_seconds
logged_in = user_has_session(username) 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 allowed_by_grant = grant_seconds > 0
should_allow = allowed_by_schedule or allowed_by_grant should_allow = allowed_by_schedule or allowed_by_grant
@@ -104,12 +105,13 @@ def evaluate_user(user_id: int, username: str):
"weekday": weekday, "weekday": weekday,
"minute": minute, "minute": minute,
"inside_window": inside_window, "inside_window": inside_window,
"has_configured_windows": bool(windows),
"logged_in": logged_in, "logged_in": logged_in,
"allowance_seconds": allowance_seconds, "allowance_seconds": allowance_seconds,
"usage_seconds": usage_seconds, "usage_seconds": usage_seconds,
"allowance_remaining": allowance_remaining, "allowance_remaining": allowance_remaining,
"grant_seconds": grant_seconds, "grant_seconds": grant_seconds,
"total_remaining": total_remaining, "normal_access_available": normal_access_available,
"allowed": should_allow, "allowed": should_allow,
} }
+52 -10
View File
@@ -1,3 +1,4 @@
import os
import secrets import secrets
from pathlib import Path from pathlib import Path
@@ -23,9 +24,12 @@ from .storage import (
add_grant, add_grant,
list_grants, list_grants,
is_admin_allowed, is_admin_allowed,
get_usage_history,
) )
from .users import ( from .users import (
linux_user_exists, linux_user_exists,
is_non_root_user,
list_available_users,
lock_user, lock_user,
unlock_user, unlock_user,
terminate_user, terminate_user,
@@ -34,13 +38,13 @@ from .users import (
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
SESSION_SECRET = __import__("os").environ.get( SESSION_SECRET = os.environ.get(
"PARENTAL_CONTROL_SESSION_SECRET" "PARENTAL_CONTROL_SESSION_SECRET"
) or secrets.token_urlsafe(32) ) or secrets.token_urlsafe(32)
app = FastAPI( app = FastAPI(
title="Parental Control", title="Parental Control",
version="0.2.0", version="0.3.0",
) )
app.add_middleware( app.add_middleware(
@@ -49,9 +53,7 @@ app.add_middleware(
session_cookie="parental_control_session", session_cookie="parental_control_session",
max_age=8 * 60 * 60, max_age=8 * 60 * 60,
same_site="lax", same_site="lax",
https_only=__import__("os").environ.get( https_only=os.environ.get("PARENTAL_CONTROL_HTTPS_ONLY", "0") == "1",
"PARENTAL_CONTROL_HTTPS_ONLY", "0"
) == "1",
) )
templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@@ -83,6 +85,8 @@ def current_user(request: Request):
if not username: if not username:
return None 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): if not is_admin_allowed(username):
request.session.clear() request.session.clear()
return None return None
@@ -100,7 +104,6 @@ def require_web_auth(request: Request):
f"/login?next={next_path}", f"/login?next={next_path}",
status_code=303, status_code=303,
) )
return None return None
@@ -144,16 +147,19 @@ class GrantRequest(BaseModel):
def root(): def root():
return { return {
"application": "Parental Control", "application": "Parental Control",
"version": "0.2.0", "version": "0.3.0",
"status": "running", "status": "running",
"authentication": "PAM", "authentication": "PAM + pam Linux group",
} }
@app.get("/login") @app.get("/login")
def login_page(request: Request, next: str = "/admin"): def login_page(request: Request, next: str = "/admin"):
if current_user(request): 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( return templates.TemplateResponse(
request=request, request=request,
@@ -161,6 +167,7 @@ def login_page(request: Request, next: str = "/admin"):
context={ context={
"next": next if next.startswith("/") else "/admin", "next": next if next.startswith("/") else "/admin",
"error": None, "error": None,
"pam_group": get_config()["auth"].get("pam_group", "pam"),
}, },
) )
@@ -191,6 +198,7 @@ def login(
context={ context={
"next": safe_next, "next": safe_next,
"error": "Invalid Linux username or password.", "error": "Invalid Linux username or password.",
"pam_group": get_config()["auth"].get("pam_group", "pam"),
}, },
status_code=401, status_code=401,
) )
@@ -201,7 +209,8 @@ def login(
name="login.html", name="login.html",
context={ context={
"next": safe_next, "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, 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") @app.post("/api/users/{user_id}/lock")
def manually_lock(user_id: int, request: Request): def manually_lock(user_id: int, request: Request):
require_api_auth(request) require_api_auth(request)
@@ -318,13 +335,21 @@ def admin_page(request: Request):
if redirect: if redirect:
return 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( return templates.TemplateResponse(
request=request, request=request,
name="index.html", name="index.html",
context={ context={
"users": list_users(), "users": list_users(),
"available_users": available_users,
"username": current_user(request), "username": current_user(request),
"csrf_token": csrf_token(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"])), 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( return templates.TemplateResponse(
request=request, request=request,
name="user.html", name="user.html",
@@ -359,6 +390,11 @@ def admin_user_page(request: Request, user_id: int):
"allowances": allowances, "allowances": allowances,
"windows": windows, "windows": windows,
"grants": list_grants(user_id), "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), "csrf_token": csrf_token(request),
"username": current_user(request), "username": current_user(request),
}, },
@@ -531,6 +567,12 @@ def admin_add_user(
if not linux_user_exists(username): 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")
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: if get_user_by_username(username) is not None:
raise HTTPException(status_code=400, detail="User is already configured") raise HTTPException(status_code=400, detail="User is already configured")
+35 -108
View File
@@ -1,156 +1,86 @@
import threading import threading
import time import time
from datetime import datetime from datetime import date
from .enforcement import enforce_all_users 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 CHECK_INTERVAL = 5
class Scheduler: class Scheduler:
def __init__(self, interval: int = CHECK_INTERVAL):
def __init__(
self,
interval: int = CHECK_INTERVAL,
):
self.interval = interval self.interval = interval
self._thread = None self._thread = None
self._stop_event = threading.Event() self._stop_event = threading.Event()
self._last_usage_update = {} self._last_usage_update = {}
def start(self): 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 return
self._stop_event.clear() self._stop_event.clear()
self._thread = threading.Thread( self._thread = threading.Thread(
target=self._run, target=self._run,
name="parental-control-scheduler", name="parental-control-scheduler",
daemon=True, daemon=True,
) )
self._thread.start() self._thread.start()
def stop(self): def stop(self):
self._stop_event.set() self._stop_event.set()
if self._thread is not None: if self._thread is not None:
self._thread.join( self._thread.join(timeout=self.interval + 2)
timeout=self.interval + 2
)
def _run(self): def _run(self):
# Evaluate immediately when the
# application starts.
self._tick() self._tick()
while not self._stop_event.wait(self.interval):
while not self._stop_event.wait(
self.interval
):
self._tick() self._tick()
def _tick(self): def _tick(self):
now = time.monotonic() now = time.monotonic()
results = enforce_all_users() results = enforce_all_users()
for result in results: for result in results:
user_id = result["user_id"] user_id = result["user_id"]
previous = self._last_usage_update.get(user_id)
if not result["logged_in"]: if previous is not None:
self._last_usage_update.pop( elapsed = max(0, int(now - previous["monotonic"]))
user_id, if elapsed > 0 and previous["allowed"] and previous["logged_in"]:
None, self._record_allowed_usage(
) user_id,
continue elapsed,
previous["weekday"],
previous["date"],
)
if not result["allowed"]: if result["logged_in"] and result["allowed"]:
self._last_usage_update.pop( self._last_usage_update[user_id] = {
user_id, "monotonic": now,
None, "allowed": True,
) "logged_in": True,
continue "weekday": result["weekday"],
"date": result["timestamp"][:10],
previous = ( }
self._last_usage_update.get( else:
user_id self._last_usage_update.pop(user_id, None)
)
)
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,
):
def _record_allowed_usage(self, user_id: int, seconds: int, weekday: int, usage_date: str):
if seconds <= 0: if seconds <= 0:
return return
weekday = datetime.now().weekday()
( (
allowance_seconds, allowance_seconds,
usage_seconds, usage_seconds,
windows, _windows,
grant_seconds, grant_seconds,
) = get_user_policy( ) = get_user_policy(user_id, weekday)
user_id,
weekday,
)
allowance_remaining = max( allowance_remaining = max(0, allowance_seconds - usage_seconds)
0, normal_usage = min(seconds, allowance_remaining)
allowance_seconds grant_usage = min(max(0, seconds - normal_usage), grant_seconds)
- usage_seconds, total_usage = normal_usage + grant_usage
)
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: if total_usage <= 0:
return return
@@ -158,14 +88,11 @@ class Scheduler:
record_usage( record_usage(
user_id, user_id,
total_usage, total_usage,
date.fromisoformat(usage_date),
) )
if grant_usage > 0: if grant_usage > 0:
consume_grant_seconds(user_id, grant_usage)
consume_grant_seconds(
user_id,
grant_usage,
)
scheduler = Scheduler() scheduler = Scheduler()
+75 -40
View File
@@ -1,10 +1,9 @@
import json import json
import os import os
import tempfile import tempfile
from contextlib import contextmanager from datetime import datetime, date, timedelta
from datetime import datetime
from pathlib import Path
from threading import RLock from threading import RLock
from pathlib import Path
import yaml import yaml
@@ -19,7 +18,7 @@ DEFAULT_CONFIG = {
"version": 1, "version": 1,
"auth": { "auth": {
"pam_service": "login", "pam_service": "login",
"admin_users": [], "pam_group": "pam",
}, },
"users": [], "users": [],
} }
@@ -68,9 +67,13 @@ def _load_yaml():
data.setdefault("version", 1) data.setdefault("version", 1)
data.setdefault("auth", {}) 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("pam_service", "login")
data["auth"].setdefault("admin_users", []) data["auth"].setdefault("pam_group", "pam")
data.setdefault("users", []) data.setdefault("users", [])
if not isinstance(data["users"], list):
raise ValueError("config.yaml users must be a list")
return data return data
@@ -124,8 +127,6 @@ def initialize_storage():
if not STATE_PATH.exists(): if not STATE_PATH.exists():
_save_json(DEFAULT_STATE) _save_json(DEFAULT_STATE)
# Keep files usable after manual edits while avoiding destructive
# initialization or recreation of any database.
config = _load_yaml() config = _load_yaml()
state = _load_json() state = _load_json()
_save_yaml(config) _save_yaml(config)
@@ -141,14 +142,14 @@ def get_pam_service():
return get_config()["auth"].get("pam_service", "login") return get_config()["auth"].get("pam_service", "login")
def admin_users(): def get_pam_group():
value = get_config()["auth"].get("admin_users", []) return get_config()["auth"].get("pam_group", "pam")
return {str(item) for item in value}
def is_admin_allowed(username: str) -> bool: def is_admin_allowed(username: str) -> bool:
allowed = admin_users() """Keep authorization in users.py so PAM group membership is system-backed."""
return not allowed or username in allowed from .users import user_in_group
return user_in_group(username, get_pam_group())
def _find_user(config, user_id): 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") raise KeyError("User not found")
user.setdefault("allowances", {}) user.setdefault("allowances", {})
user["allowances"][str(weekday)] = int(seconds) user["allowances"][str(weekday)] = max(0, int(seconds))
_save_yaml(config) _save_yaml(config)
@@ -298,7 +299,26 @@ def delete_window(window_id: int):
return int(owner["id"]) 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: with _lock:
config = _load_yaml() config = _load_yaml()
user = _find_user(config, user_id) user = _find_user(config, user_id)
@@ -318,22 +338,14 @@ def get_user_policy(user_id: int, weekday: int):
) )
state = _load_json() state = _load_json()
today = datetime.now().date().isoformat() target_date = on_date or datetime.now().date()
today = target_date.isoformat()
usage_seconds = int( usage_seconds = int(
state["usage"].get(f"{int(user_id)}:{today}", 0) state["usage"].get(f"{int(user_id)}:{today}", 0)
) )
now = datetime.now().isoformat() now = datetime.now().isoformat()
grant_seconds = sum( grant_seconds = _active_grant_seconds(state, user_id, now)
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 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: def get_remaining_grant_seconds(user_id: int) -> int:
with _lock: with _lock:
state = _load_json() state = _load_json()
now = datetime.now().isoformat() return _active_grant_seconds(
return sum( state,
int(grant["remaining_seconds"]) user_id,
for grant in state["temporary_grants"] datetime.now().isoformat(),
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
)
) )
@@ -407,18 +412,49 @@ def consume_grant_seconds(user_id: int, seconds: int):
_save_json(state) _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: if seconds <= 0:
return return
with _lock: with _lock:
state = _load_json() state = _load_json()
today = datetime.now().date().isoformat() target_date = usage_date or datetime.now().date()
key = f"{int(user_id)}:{today}" key = f"{int(user_id)}:{target_date.isoformat()}"
state["usage"][key] = int(state["usage"].get(key, 0)) + int(seconds) state["usage"][key] = int(state["usage"].get(key, 0)) + int(seconds)
_save_json(state) _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): def list_grants(user_id: int, limit: int = 20):
with _lock: with _lock:
state = _load_json() state = _load_json()
@@ -440,6 +476,5 @@ def record_event(user_id, event_type: str, details: str = ""):
"details": details, "details": details,
"created_at": datetime.now().isoformat(timespec="seconds"), "created_at": datetime.now().isoformat(timespec="seconds"),
}) })
# Keep the state file bounded.
state["events"] = state["events"][-2000:] state["events"] = state["events"][-2000:]
_save_json(state) _save_json(state)
+114
View File
@@ -1,5 +1,7 @@
import grp
import pwd import pwd
import subprocess import subprocess
from typing import List, Dict
def linux_user_exists(username: str) -> bool: def linux_user_exists(username: str) -> bool:
@@ -14,6 +16,118 @@ def get_uid(username: str) -> int:
return pwd.getpwnam(username).pw_uid 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: def is_locked(username: str) -> bool:
result = subprocess.run( result = subprocess.run(
["passwd", "-S", username], ["passwd", "-S", username],
+1 -2
View File
@@ -1,6 +1,5 @@
version: 1 version: 1
auth: auth:
pam_service: login pam_service: login
admin_users: pam_group: pam
- root
users: [] users: []
+11
View File
@@ -0,0 +1,11 @@
{
"version": 1,
"next_ids": {
"user": 3,
"window": 2,
"grant": 1
},
"usage": {},
"temporary_grants": [],
"events": []
}
+24 -5
View File
@@ -113,7 +113,9 @@ echo "[1/5] Installing dependencies"
case "$PACKAGE_MANAGER" in case "$PACKAGE_MANAGER" in
pacman) 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 \
python-pip python-pip
;; ;;
@@ -156,7 +158,6 @@ case "$PACKAGE_MANAGER" in
esac esac
# ============================================================ # ============================================================
# Verify Python # Verify Python
# ============================================================ # ============================================================
@@ -174,6 +175,27 @@ echo "Python:"
echo 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 # Create virtual environment
# ============================================================ # ============================================================
@@ -194,9 +216,6 @@ fi
echo echo
echo "[3/5] Installing Python packages" echo "[3/5] Installing Python packages"
"$INSTALL_DIR/.venv/bin/python" -m pip install \
--upgrade pip
"$INSTALL_DIR/.venv/bin/python" -m pip install \ "$INSTALL_DIR/.venv/bin/python" -m pip install \
-r "$INSTALL_DIR/requirements.txt" -r "$INSTALL_DIR/requirements.txt"
+5
View File
@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.pyc
data/state.json
data/*.db
+1 -1
View File
@@ -11,7 +11,7 @@ Environment="PATH=%INSTALL_DIR%/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/bi
Environment="PARENTAL_CONTROL_ENFORCEMENT=1" Environment="PARENTAL_CONTROL_ENFORCEMENT=1"
EnvironmentFile=-/etc/parental-control/session-secret.env 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 Restart=always
RestartSec=5 RestartSec=5
+1
View File
@@ -16,4 +16,5 @@ starlette==1.6.0
typing-inspection==0.4.4 typing-inspection==0.4.4
typing_extensions==4.16.0 typing_extensions==4.16.0
uvicorn==0.53.0 uvicorn==0.53.0
itsdangerous>=2.2.0
itsdangerous itsdangerous
+60 -397
View File
@@ -1,445 +1,108 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>Parental Control</title> <title>Parental Control</title>
<style> <style>
* { box-sizing: border-box; }
* { body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f4f5f7; color: #1f2937; }
box-sizing: border-box; header { background: #111827; color: white; padding: 20px 30px; }
} header h1 { margin: 0; font-size: 24px; }
main { max-width: 1100px; margin: 30px auto; padding: 0 20px; }
body { .topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; gap: 15px; }
margin: 0; .card { background: white; border-radius: 12px; padding: 20px; margin-bottom: 15px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
font-family: .user-header { display: flex; justify-content: space-between; align-items: center; gap: 15px; flex-wrap: wrap; }
system-ui, .username { font-size: 20px; font-weight: 600; }
-apple-system, .muted { color: #6b7280; font-size: 14px; }
BlinkMacSystemFont, .status { display: inline-block; padding: 5px 10px; border-radius: 20px; font-size: 13px; }
"Segoe UI", .status-enabled { background: #dcfce7; color: #166534; }
sans-serif; .status-locked { background: #fee2e2; color: #991b1b; }
.actions { display: flex; gap: 10px; margin-top: 15px; flex-wrap: wrap; }
background: #f4f5f7; button, .button { border: 0; border-radius: 7px; padding: 9px 15px; cursor: pointer; font-size: 14px; text-decoration: none; display: inline-block; }
color: #1f2937; .button-primary { background: #2563eb; color: white; }
} .button-danger { background: #dc2626; color: white; }
.button-secondary { background: #e5e7eb; color: #111827; }
.add-user form { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
header { input, select { border: 1px solid #d1d5db; border-radius: 7px; padding: 9px 12px; font-size: 14px; background: white; }
select { min-width: 320px; }
background: #111827; .empty { color: #6b7280; }
color: white; .info { margin-top: 10px; padding: 12px 14px; background: #eff6ff; border-radius: 8px; color: #1e40af; font-size: 14px; }
@media (max-width: 700px) { select { min-width: 100%; } }
padding: 20px 30px;
}
header h1 {
margin: 0;
font-size: 24px;
}
main {
max-width: 1100px;
margin: 30px auto;
padding: 0 20px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
}
.topbar h2 {
margin: 0;
}
.card {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 15px;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08);
}
.user-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.username {
font-size: 20px;
font-weight: 600;
}
.status {
display: inline-block;
padding: 5px 10px;
border-radius: 20px;
font-size: 13px;
}
.status-enabled {
background: #dcfce7;
color: #166534;
}
.status-disabled {
background: #fee2e2;
color: #991b1b;
}
.actions {
display: flex;
gap: 10px;
margin-top: 15px;
flex-wrap: wrap;
}
button {
border: 0;
border-radius: 7px;
padding: 9px 15px;
cursor: pointer;
font-size: 14px;
}
.button-primary {
background: #2563eb;
color: white;
}
.button-danger {
background: #dc2626;
color: white;
}
.button-secondary {
background: #e5e7eb;
color: #111827;
}
.add-user {
margin-top: 30px;
}
.add-user form {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
input {
border: 1px solid #d1d5db;
border-radius: 7px;
padding: 9px 12px;
font-size: 14px;
}
.empty {
color: #6b7280;
}
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<h1>Parental Control</h1>
<h1> <div style="margin-top:8px;font-size:14px;">
Parental Control Signed in as <strong>{{ username }}</strong>
</h1>
<div style="margin-top:8px;font-size:14px;">Signed in as <strong>{{ username }}</strong>
<form method="post" action="/logout" style="display:inline;margin-left:12px;"> <form method="post" action="/logout" style="display:inline;margin-left:12px;">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input type="hidden" name="csrf" value="{{ csrf_token }}"> <input type="hidden" name="csrf" value="{{ csrf_token }}">
<button type="submit" class="button-secondary">Log out</button> <button type="submit" class="button-secondary">Log out</button>
</form> </form>
</div> </div>
</header> </header>
<main> <main>
<div class="topbar"> <div class="topbar">
<div>
<h2> <h2 style="margin:0;">Users</h2>
Users <div class="muted" style="margin-top:6px;">Configure Linux accounts controlled by this service.</div>
</h2> </div>
</div> </div>
{% if users %} {% if users %}
{% for user in users %} {% for user in users %}
<div class="card"> <div class="card">
<div class="user-header"> <div class="user-header">
<div> <div>
<div class="username">{{ user.username }}</div>
<div class="username"> <div class="muted">Application user ID: {{ user.id }}</div>
{{ user.username }}
</div>
<div>
Linux user ID:
{{ user.id }}
</div>
</div> </div>
{% if user.enabled %} {% if user.enabled %}
<span class="status status-enabled">Enabled</span>
<span class="status status-enabled">
Enabled
</span>
{% else %}
<span class="status status-disabled">
Disabled
</span>
{% endif %} {% endif %}
</div> </div>
<div class="actions"> <div class="actions">
<a class="button button-primary" href="/admin/users/{{ user.id }}">Manage</a>
<form method="post" action="/admin/users/{{ user.id }}/delete">
<a
href="/admin/users/{{ user.id }}"
>
<button
class="button-primary"
type="button"
>
Manage
</button>
</a>
<form
method="post"
action="/admin/users/{{ user.id }}/delete"
>
<input type="hidden" name="csrf" value="{{ csrf_token }}"> <input type="hidden" name="csrf" value="{{ csrf_token }}">
<button class="button-danger" type="submit" onclick="return confirm('Remove this user from parental control?');">Delete</button>
<button
class="button-danger"
type="submit"
>
Delete
</button>
</form> </form>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<div class="card empty">No users configured yet.</div>
<div class="card empty">
No users configured yet.
</div>
{% endif %} {% endif %}
<div class="card add-user"> <div class="card add-user">
<h2 style="margin-top:0;">Add User</h2>
<p class="muted">Select an existing regular Linux account. Root, system accounts, and accounts with standard sudo/wheel access are excluded.</p>
{% if available_users %}
<h2> <form method="post" action="/admin/users">
Add User <input type="hidden" name="csrf" value="{{ csrf_token }}">
</h2> <select name="username" required>
<option value="" selected disabled>Select a Linux user</option>
{% for candidate in available_users %}
<p> <option value="{{ candidate.username }}">
{{ candidate.username }}
Add an existing Linux account to </option>
parental control. {% endfor %}
</select>
</p> <button class="button-primary" type="submit">Add User</button>
<form
method="post"
action="/admin/users"
>
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<input
type="text"
name="username"
placeholder="Linux username"
required
>
<button
class="button-primary"
type="submit"
>
Add User
</button>
</form> </form>
{% else %}
<div class="info">No eligible non-root Linux users are currently available to add.</div>
{% endif %}
</div> </div>
<div class="card">
<strong>PAM administration group:</strong> <code>{{ pam_group }}</code>
<div class="muted" style="margin-top:6px;">Only Linux users who authenticate with PAM and belong to this group can use the web panel.</div>
</div>
</main> </main>
</body> </body>
</html> </html>
+17 -64
View File
@@ -3,80 +3,33 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in — Parental Control</title> <title>Parental Control Login</title>
<style> <style>
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f4f5f7; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #1f2937; }
margin: 0; .card { width: min(420px, calc(100% - 32px)); background: white; padding: 28px; border-radius: 14px; box-shadow: 0 4px 20px rgba(0,0,0,.08); }
min-height: 100vh; h1 { margin-top: 0; }
display: grid; label { display: block; margin: 14px 0 6px; font-size: 14px; font-weight: 600; }
place-items: center; input { width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 15px; }
background: #f4f5f7; button { width: 100%; margin-top: 20px; padding: 10px 14px; border: 0; border-radius: 8px; background: #2563eb; color: white; font-size: 15px; cursor: pointer; }
color: #1f2937; .error { padding: 10px 12px; border-radius: 8px; background: #fee2e2; color: #991b1b; margin-bottom: 14px; font-size: 14px; }
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; .muted { color: #6b7280; font-size: 14px; line-height: 1.5; }
} code { background: #f3f4f6; padding: 2px 5px; border-radius: 4px; }
.card {
width: min(420px, calc(100% - 32px));
background: white;
border-radius: 12px;
padding: 28px;
box-shadow: 0 2px 12px rgba(0,0,0,.10);
}
h1 { margin: 0 0 8px; }
p { color: #6b7280; }
label { display:block; margin-top:16px; font-weight:600; font-size:14px; }
input {
width:100%;
margin-top:7px;
border:1px solid #d1d5db;
border-radius:7px;
padding:10px 12px;
font-size:15px;
}
button {
width:100%;
margin-top:22px;
border:0;
border-radius:7px;
padding:11px 15px;
background:#2563eb;
color:white;
cursor:pointer;
font-size:15px;
}
.error {
background:#fef2f2;
color:#991b1b;
border:1px solid #fecaca;
border-radius:7px;
padding:10px 12px;
margin-top:16px;
}
.hint { font-size:13px; }
</style> </style>
</head> </head>
<body> <body>
<main class="card"> <div class="card">
<h1>Parental Control</h1> <h1>Parental Control</h1>
<p>Sign in with a Linux account authenticated through PAM.</p> <p class="muted">Sign in with your Linux username and password. You must also be a member of the <code>{{ pam_group }}</code> Linux group.</p>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="post" action="/login"> <form method="post" action="/login">
<input type="hidden" name="next" value="{{ next }}"> <input type="hidden" name="next" value="{{ next }}">
<label for="username">Linux username</label> <label for="username">Linux username</label>
<input id="username" name="username" type="text" autocomplete="username" required autofocus> <input id="username" name="username" autocomplete="username" required>
<label for="password">Linux password</label>
<label for="password">Password</label> <input id="password" type="password" name="password" autocomplete="current-password" required>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button> <button type="submit">Sign in</button>
</form> </form>
</div>
<p class="hint">The application does not store your Linux password.</p>
</main>
</body> </body>
</html> </html>
+243 -775
View File
File diff suppressed because it is too large Load Diff