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
__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
+70 -87
View File
@@ -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.
+9 -7
View File
@@ -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,
}
+52 -10
View File
@@ -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")
+35 -108
View File
@@ -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()
+75 -40
View File
@@ -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)
+114
View File
@@ -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],
+1 -2
View File
@@ -1,6 +1,5 @@
version: 1
auth:
pam_service: login
admin_users:
- root
pam_group: pam
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
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"
+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"
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
+1
View File
@@ -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
+60 -397
View File
@@ -1,445 +1,108 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background: #f4f5f7;
color: #1f2937;
}
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;
}
.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;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f4f5f7; color: #1f2937; }
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; }
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; gap: 15px; }
.card { background: white; border-radius: 12px; padding: 20px; margin-bottom: 15px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.user-header { display: flex; justify-content: space-between; align-items: center; gap: 15px; flex-wrap: wrap; }
.username { font-size: 20px; font-weight: 600; }
.muted { color: #6b7280; font-size: 14px; }
.status { display: inline-block; padding: 5px 10px; border-radius: 20px; font-size: 13px; }
.status-enabled { background: #dcfce7; color: #166534; }
.status-locked { background: #fee2e2; color: #991b1b; }
.actions { display: flex; gap: 10px; margin-top: 15px; flex-wrap: wrap; }
button, .button { border: 0; border-radius: 7px; padding: 9px 15px; cursor: pointer; font-size: 14px; text-decoration: none; display: inline-block; }
.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; }
input, select { border: 1px solid #d1d5db; border-radius: 7px; padding: 9px 12px; font-size: 14px; background: white; }
select { min-width: 320px; }
.empty { color: #6b7280; }
.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%; } }
</style>
</head>
<body>
<header>
<h1>
Parental Control
</h1>
<div style="margin-top:8px;font-size:14px;">Signed in as <strong>{{ username }}</strong>
<h1>Parental Control</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;">
<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>
</form>
</div>
</header>
<main>
<div class="topbar">
<h2>
Users
</h2>
<div>
<h2 style="margin:0;">Users</h2>
<div class="muted" style="margin-top:6px;">Configure Linux accounts controlled by this service.</div>
</div>
</div>
{% if users %}
{% for user in users %}
<div class="card">
<div class="user-header">
<div>
<div class="username">
{{ user.username }}
</div>
<div>
Linux user ID:
{{ user.id }}
</div>
<div class="username">{{ user.username }}</div>
<div class="muted">Application user ID: {{ user.id }}</div>
</div>
{% if user.enabled %}
<span class="status status-enabled">
Enabled
</span>
{% else %}
<span class="status status-disabled">
Disabled
</span>
<span class="status status-enabled">Enabled</span>
{% endif %}
</div>
<div class="actions">
<a
href="/admin/users/{{ user.id }}"
>
<button
class="button-primary"
type="button"
>
Manage
</button>
</a>
<form
method="post"
action="/admin/users/{{ user.id }}/delete"
>
<a class="button button-primary" href="/admin/users/{{ user.id }}">Manage</a>
<form method="post" action="/admin/users/{{ user.id }}/delete">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<button
class="button-danger"
type="submit"
>
Delete
</button>
<button class="button-danger" type="submit" onclick="return confirm('Remove this user from parental control?');">Delete</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<div class="card empty">
No users configured yet.
</div>
<div class="card empty">No users configured yet.</div>
{% endif %}
<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>
<h2>
Add User
</h2>
<p>
Add an existing Linux account to
parental control.
</p>
<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>
{% if available_users %}
<form method="post" action="/admin/users">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<select name="username" required>
<option value="" selected disabled>Select a Linux user</option>
{% for candidate in available_users %}
<option value="{{ candidate.username }}">
{{ candidate.username }}
</option>
{% endfor %}
</select>
<button class="button-primary" type="submit">Add User</button>
</form>
{% else %}
<div class="info">No eligible non-root Linux users are currently available to add.</div>
{% endif %}
</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>
</body>
</html>
+17 -64
View File
@@ -3,80 +3,33 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in — Parental Control</title>
<title>Parental Control Login</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #f4f5f7;
color: #1f2937;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.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; }
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; }
.card { width: min(420px, calc(100% - 32px)); background: white; padding: 28px; border-radius: 14px; box-shadow: 0 4px 20px rgba(0,0,0,.08); }
h1 { margin-top: 0; }
label { display: block; margin: 14px 0 6px; font-size: 14px; font-weight: 600; }
input { width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 15px; }
button { width: 100%; margin-top: 20px; padding: 10px 14px; border: 0; border-radius: 8px; background: #2563eb; color: white; font-size: 15px; cursor: pointer; }
.error { padding: 10px 12px; border-radius: 8px; background: #fee2e2; color: #991b1b; margin-bottom: 14px; font-size: 14px; }
.muted { color: #6b7280; font-size: 14px; line-height: 1.5; }
code { background: #f3f4f6; padding: 2px 5px; border-radius: 4px; }
</style>
</head>
<body>
<main class="card">
<div class="card">
<h1>Parental Control</h1>
<p>Sign in with a Linux account authenticated through PAM.</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<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 %}
<form method="post" action="/login">
<input type="hidden" name="next" value="{{ next }}">
<label for="username">Linux username</label>
<input id="username" name="username" type="text" autocomplete="username" required autofocus>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<input id="username" name="username" autocomplete="username" required>
<label for="password">Linux password</label>
<input id="password" type="password" name="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
</form>
<p class="hint">The application does not store your Linux password.</p>
</main>
</div>
</body>
</html>
+243 -775
View File
File diff suppressed because it is too large Load Diff