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