Compare commits

...
3 Commits
Author SHA1 Message Date
Alsan bd973a641c added graps 2026-09-19 12:46:23 +05:00
Alsan b5593fa7f4 added pam auth 2026-09-18 17:20:59 +05:00
Alsan 09bd167536 optimized installer 2026-09-17 14:25:22 +05:00
17 changed files with 1869 additions and 2429 deletions
+28 -4
View File
@@ -1,12 +1,36 @@
.venv/
# Python
__pycache__/
*.py[cod]
*.pyo
# Virtual environment
.venv/
venv/
env/
# Python tooling
.pytest_cache/
.mypy_cache/
.ruff_cache/
data/*.db
data/*.db-*
# Runtime/application state
data/state.json
# Local configuration
data/config.yaml
# Logs
*.log
logs/
# Environment/secrets
.env
.env.*
*.secret
*.key
.idea/
# Editor/OS files
.vscode/
.idea/
*.swp
.DS_Store
+139 -63
View File
@@ -1,63 +1,139 @@
# Linux Parental Control
A Linux parental-control system for managing Linux user access, daily time allowances, access windows, temporary grants, and automatic session enforcement.
> **Status:** Early development
## Features
- Per-user daily time allowances
- Different allowances for each day of the week
- Multiple access windows per day
- Temporary time grants
- Usage tracking
- Automatic session termination
- Automatic account locking
- Automatic account unlocking
- Reboot-safe enforcement
- Web-based administration
- SQLite database
- systemd service support
---
# Requirements
The application currently targets Linux systems using `systemd`.
You need:
- Linux
- Python 3
- `python-venv`
- `pip`
- `systemd`
- `sudo`
- `passwd`
- `loginctl`
- Git
The enforcement service requires **root privileges** because it manages other Linux users and their sessions.
---
# 1. Clone the Repository
Clone the repository:
```bash
git clone https://git.shihaam.dev/Alsan/linux-user-timer.git
```
## 2. Make it executable
```bash
chmod +x install.sh
```
## 3. Run the install script
```bash
sudo ./install.sh
```
### You can access it via http://127.0.0.1:8765/admin
# Linux Parental Control
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 uses two files under `data/`:
- `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`.
## Web authentication
The administration web interface uses Linux PAM for password authentication and a Linux group for authorization.
The default PAM settings are:
```yaml
auth:
pam_service: login
pam_group: pam
```
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`.
To grant another administrator access:
```bash
sudo usermod -aG pam username
```
To remove access:
```bash
sudo gpasswd -d username pam
```
The application re-checks group membership on each request, so a removed member cannot continue using an existing session.
## 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 Linux, Python 3, Python virtual-environment support, pip, systemd, PAM, `sudo`, `passwd`, `loginctl`, and the standard user/group management commands.
The enforcement service runs as `root` because it manages other Linux users and their sessions.
## Installation
```bash
git clone https://git.shihaam.dev/Alsan/linux-user-timer.git
cd linux-user-timer
chmod +x install.sh
sudo ./install.sh
```
The Arch Linux installer intentionally runs:
```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
```bash
sudo systemctl status parental-control
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:
```text
127.0.0.1:8765
```
Put it behind your existing Nginx/Apache/reverse proxy if remote access is required.
For HTTPS-only session cookies, set this in the systemd service:
```ini
Environment="PARENTAL_CONTROL_HTTPS_ONLY=1"
```
## 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.
-105
View File
@@ -1,105 +0,0 @@
import sqlite3
from pathlib import Path
from contextlib import contextmanager
BASE_DIR = Path(__file__).resolve().parent.parent
DATABASE_DIR = BASE_DIR / "data"
DATABASE_PATH = DATABASE_DIR / "parental-control.db"
def initialize_database():
DATABASE_DIR.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(DATABASE_PATH) as db:
db.execute("PRAGMA foreign_keys = ON")
db.executescript(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS daily_allowances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
weekday INTEGER NOT NULL,
allowance_seconds INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id, weekday),
FOREIGN KEY(user_id)
REFERENCES users(id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS access_windows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
weekday INTEGER NOT NULL,
start_minute INTEGER NOT NULL,
end_minute INTEGER NOT NULL,
FOREIGN KEY(user_id)
REFERENCES users(id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
date TEXT NOT NULL,
used_seconds INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id, date),
FOREIGN KEY(user_id)
REFERENCES users(id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS temporary_grants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
seconds INTEGER NOT NULL,
remaining_seconds INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT,
consumed INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
event_type TEXT NOT NULL,
details TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id)
REFERENCES users(id)
ON DELETE SET NULL
);
"""
)
db.commit()
@contextmanager
def get_db():
db = sqlite3.connect(DATABASE_PATH)
db.row_factory = sqlite3.Row
db.execute("PRAGMA foreign_keys = ON")
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
+36 -362
View File
@@ -1,7 +1,11 @@
from datetime import datetime
import subprocess
from .database import get_db
from .storage import (
get_user_policy,
record_event,
list_users,
)
from .users import (
lock_user,
unlock_user,
@@ -12,11 +16,7 @@ from .users import (
def user_has_session(username: str) -> bool:
result = subprocess.run(
[
"loginctl",
"list-users",
"--no-legend",
],
["loginctl", "list-users", "--no-legend"],
capture_output=True,
text=True,
check=False,
@@ -25,297 +25,29 @@ def user_has_session(username: str) -> bool:
if result.returncode != 0:
return False
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[1] == username:
return True
return False
return any(
len(parts := line.split()) >= 2 and parts[1] == username
for line in result.stdout.splitlines()
)
def current_time():
now = datetime.now()
weekday = now.weekday()
minute = now.hour * 60 + now.minute
return now, weekday, minute
def get_user_policy(user_id: int, weekday: int):
with get_db() as db:
allowance_row = db.execute(
"""
SELECT allowance_seconds
FROM daily_allowances
WHERE user_id = ?
AND weekday = ?
""",
(
user_id,
weekday,
),
).fetchone()
allowance_seconds = (
allowance_row["allowance_seconds"]
if allowance_row
else 0
)
today = datetime.now().date().isoformat()
usage_row = db.execute(
"""
SELECT used_seconds
FROM usage
WHERE user_id = ?
AND date = ?
""",
(
user_id,
today,
),
).fetchone()
usage_seconds = (
usage_row["used_seconds"]
if usage_row
else 0
)
windows = db.execute(
"""
SELECT id, start_minute, end_minute
FROM access_windows
WHERE user_id = ?
AND weekday = ?
ORDER BY start_minute
""",
(
user_id,
weekday,
),
).fetchall()
grant_row = db.execute(
"""
SELECT COALESCE(
SUM(remaining_seconds),
0
) AS total
FROM temporary_grants
WHERE user_id = ?
AND consumed = 0
AND (
expires_at IS NULL
OR expires_at > ?
)
""",
(
user_id,
datetime.now().isoformat(),
),
).fetchone()
grant_seconds = grant_row["total"]
return (
allowance_seconds,
usage_seconds,
windows,
grant_seconds,
)
return now, now.weekday(), now.hour * 60 + now.minute
def is_inside_window(windows, minute: int) -> bool:
# No configured windows means that no time-of-day restriction exists.
if not windows:
return True
for window in windows:
if (
window["start_minute"]
<= minute
< window["end_minute"]
):
return True
return False
return any(
int(window["start_minute"]) <= minute < int(window["end_minute"])
for window in windows
)
def get_remaining_grant_seconds(user_id: int) -> int:
now = datetime.now().isoformat()
with get_db() as db:
row = db.execute(
"""
SELECT COALESCE(
SUM(remaining_seconds),
0
) AS total
FROM temporary_grants
WHERE user_id = ?
AND consumed = 0
AND (
expires_at IS NULL
OR expires_at > ?
)
""",
(
user_id,
now,
),
).fetchone()
return row["total"]
def consume_grant_seconds(
user_id: int,
seconds: int,
):
if seconds <= 0:
return
now = datetime.now().isoformat()
with get_db() as db:
grants = db.execute(
"""
SELECT id, remaining_seconds
FROM temporary_grants
WHERE user_id = ?
AND consumed = 0
AND remaining_seconds > 0
AND (
expires_at IS NULL
OR expires_at > ?
)
ORDER BY id ASC
""",
(
user_id,
now,
),
).fetchall()
remaining = seconds
for grant in grants:
if remaining <= 0:
break
available = grant["remaining_seconds"]
consumed = min(
available,
remaining,
)
new_remaining = (
available - consumed
)
db.execute(
"""
UPDATE temporary_grants
SET remaining_seconds = ?,
consumed = ?
WHERE id = ?
""",
(
new_remaining,
1 if new_remaining <= 0 else 0,
grant["id"],
),
)
remaining -= consumed
def record_usage(
user_id: int,
seconds: int,
):
if seconds <= 0:
return
today = datetime.now().date().isoformat()
with get_db() as db:
row = db.execute(
"""
SELECT used_seconds
FROM usage
WHERE user_id = ?
AND date = ?
""",
(
user_id,
today,
),
).fetchone()
if row is None:
db.execute(
"""
INSERT INTO usage (
user_id,
date,
used_seconds
)
VALUES (?, ?, ?)
""",
(
user_id,
today,
seconds,
),
)
else:
db.execute(
"""
UPDATE usage
SET used_seconds =
used_seconds + ?
WHERE user_id = ?
AND date = ?
""",
(
seconds,
user_id,
today,
),
)
def record_event(
user_id: int,
event_type: str,
details: str = "",
):
with get_db() as db:
db.execute(
"""
INSERT INTO events (
user_id,
event_type,
details
)
VALUES (?, ?, ?)
""",
(
user_id,
event_type,
details,
),
)
def evaluate_user(
user_id: int,
username: str,
):
def evaluate_user(user_id: int, username: str):
now, weekday, minute = current_time()
(
@@ -323,43 +55,19 @@ def evaluate_user(
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,
)
inside_window = is_inside_window(windows, minute)
allowance_remaining = max(0, allowance_seconds - usage_seconds)
logged_in = user_has_session(username)
allowance_remaining = max(
0,
allowance_seconds - usage_seconds,
)
total_remaining = (
allowance_remaining
+ grant_seconds
)
logged_in = user_has_session(
username
)
allowed_by_schedule = (
inside_window
and allowance_remaining > 0
)
allowed_by_grant = (
grant_seconds > 0
)
should_allow = (
allowed_by_schedule
or allowed_by_grant
)
# 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
locked = is_locked(username)
@@ -367,23 +75,12 @@ def evaluate_user(
if locked:
try:
unlock_user(username)
record_event(
user_id,
"auto_unlock",
"Access became available",
)
record_event(user_id, "auto_unlock", "Access became available")
except Exception as exc:
record_event(
user_id,
"unlock_error",
str(exc),
)
record_event(user_id, "unlock_error", str(exc))
else:
if logged_in:
terminate_user(username)
record_event(
user_id,
"session_terminated",
@@ -393,18 +90,13 @@ def evaluate_user(
if not locked:
try:
lock_user(username)
record_event(
user_id,
"auto_lock",
"Access is not currently permitted",
)
except Exception as exc:
record_event(
user_id,
"lock_error",
str(exc),
)
record_event(user_id, "lock_error", str(exc))
return {
"user_id": user_id,
@@ -413,43 +105,25 @@ def evaluate_user(
"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,
}
def enforce_all_users():
with get_db() as db:
users = db.execute(
"""
SELECT id, username, enabled
FROM users
WHERE enabled = 1
ORDER BY id
"""
).fetchall()
users = list_users()
results = []
for user in users:
try:
result = evaluate_user(
user["id"],
user["username"],
)
results.append(result)
results.append(evaluate_user(user["id"], user["username"]))
except Exception as exc:
record_event(
user["id"],
"enforcement_error",
str(exc),
)
record_event(user["id"], "enforcement_error", str(exc))
return results
+374 -611
View File
File diff suppressed because it is too large Load Diff
+36 -113
View File
@@ -1,160 +1,86 @@
import threading
import time
from datetime import datetime
from .enforcement import (
enforce_all_users,
record_usage,
get_user_policy,
consume_grant_seconds,
)
from datetime import date
from .enforcement import enforce_all_users
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
@@ -162,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()
+480
View File
@@ -0,0 +1,480 @@
import json
import os
import tempfile
from datetime import datetime, date, timedelta
from threading import RLock
from pathlib import Path
import yaml
BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data"
CONFIG_PATH = DATA_DIR / "config.yaml"
STATE_PATH = DATA_DIR / "state.json"
_lock = RLock()
DEFAULT_CONFIG = {
"version": 1,
"auth": {
"pam_service": "login",
"pam_group": "pam",
},
"users": [],
}
DEFAULT_STATE = {
"version": 1,
"next_ids": {
"user": 1,
"window": 1,
"grant": 1,
},
"usage": {},
"temporary_grants": [],
"events": [],
}
def _atomic_write(path: Path, content: str, mode: int = 0o600):
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
prefix=f".{path.name}.",
dir=str(path.parent),
text=True,
)
try:
os.fchmod(fd, mode)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_name, path)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
def _load_yaml():
if not CONFIG_PATH.exists():
return json.loads(json.dumps(DEFAULT_CONFIG))
with CONFIG_PATH.open("r", encoding="utf-8") as handle:
data = yaml.safe_load(handle) or {}
if not isinstance(data, dict):
raise ValueError("config.yaml must contain a YAML object")
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("pam_group", "pam")
data.setdefault("users", [])
if not isinstance(data["users"], list):
raise ValueError("config.yaml users must be a list")
return data
def _load_json():
if not STATE_PATH.exists():
return json.loads(json.dumps(DEFAULT_STATE))
with STATE_PATH.open("r", encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
raise ValueError("state.json must contain a JSON object")
data.setdefault("version", 1)
data.setdefault("next_ids", {})
data["next_ids"].setdefault("user", 1)
data["next_ids"].setdefault("window", 1)
data["next_ids"].setdefault("grant", 1)
data.setdefault("usage", {})
data.setdefault("temporary_grants", [])
data.setdefault("events", [])
return data
def _save_yaml(data):
_atomic_write(
CONFIG_PATH,
yaml.safe_dump(
data,
sort_keys=False,
default_flow_style=False,
allow_unicode=True,
),
)
def _save_json(data):
_atomic_write(
STATE_PATH,
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
)
def initialize_storage():
DATA_DIR.mkdir(parents=True, exist_ok=True)
with _lock:
if not CONFIG_PATH.exists():
_save_yaml(DEFAULT_CONFIG)
if not STATE_PATH.exists():
_save_json(DEFAULT_STATE)
config = _load_yaml()
state = _load_json()
_save_yaml(config)
_save_json(state)
def get_config():
with _lock:
return _load_yaml()
def get_pam_service():
return get_config()["auth"].get("pam_service", "login")
def get_pam_group():
return get_config()["auth"].get("pam_group", "pam")
def is_admin_allowed(username: str) -> bool:
"""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):
for user in config["users"]:
if int(user["id"]) == int(user_id):
return user
return None
def _find_window(config, window_id):
for user in config["users"]:
for window in user.get("windows", []):
if int(window["id"]) == int(window_id):
return user, window
return None, None
def list_users():
with _lock:
config = _load_yaml()
return sorted(
[dict(user) for user in config["users"] if user.get("enabled", True)],
key=lambda item: item["username"],
)
def get_user(user_id: int):
with _lock:
config = _load_yaml()
user = _find_user(config, user_id)
return dict(user) if user else None
def get_user_by_username(username: str):
with _lock:
config = _load_yaml()
for user in config["users"]:
if user["username"] == username:
return dict(user)
return None
def add_user(username: str):
with _lock:
config = _load_yaml()
if any(u["username"] == username for u in config["users"]):
raise ValueError("User is already configured")
state = _load_json()
user_id = int(state["next_ids"]["user"])
state["next_ids"]["user"] = user_id + 1
user = {
"id": user_id,
"username": username,
"enabled": True,
"allowances": {str(day): 0 for day in range(7)},
"windows": [],
}
config["users"].append(user)
_save_yaml(config)
_save_json(state)
return dict(user)
def delete_user(user_id: int):
with _lock:
config = _load_yaml()
user = _find_user(config, user_id)
if user is None:
return False
username = user["username"]
config["users"] = [
item for item in config["users"]
if int(item["id"]) != int(user_id)
]
state = _load_json()
state["usage"] = {
key: value
for key, value in state["usage"].items()
if not key.startswith(f"{int(user_id)}:")
}
state["temporary_grants"] = [
grant for grant in state["temporary_grants"]
if int(grant["user_id"]) != int(user_id)
]
state["events"] = [
event for event in state["events"]
if event.get("user_id") is None
or int(event["user_id"]) != int(user_id)
]
_save_yaml(config)
_save_json(state)
return username
def set_allowance(user_id: int, weekday: int, seconds: int):
with _lock:
config = _load_yaml()
user = _find_user(config, user_id)
if user is None:
raise KeyError("User not found")
user.setdefault("allowances", {})
user["allowances"][str(weekday)] = max(0, int(seconds))
_save_yaml(config)
def add_window(user_id: int, weekday: int, start_minute: int, end_minute: int):
with _lock:
config = _load_yaml()
user = _find_user(config, user_id)
if user is None:
raise KeyError("User not found")
state = _load_json()
window_id = int(state["next_ids"]["window"])
state["next_ids"]["window"] = window_id + 1
user.setdefault("windows", []).append({
"id": window_id,
"weekday": int(weekday),
"start_minute": int(start_minute),
"end_minute": int(end_minute),
})
_save_yaml(config)
_save_json(state)
return window_id
def delete_window(window_id: int):
with _lock:
config = _load_yaml()
owner, window = _find_window(config, window_id)
if owner is None:
return None
owner["windows"] = [
item for item in owner.get("windows", [])
if int(item["id"]) != int(window_id)
]
_save_yaml(config)
return int(owner["id"])
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)
if user is None:
return 0, 0, [], 0
allowance_seconds = int(
user.get("allowances", {}).get(str(weekday), 0)
)
windows = sorted(
[
dict(window)
for window in user.get("windows", [])
if int(window["weekday"]) == int(weekday)
],
key=lambda item: int(item["start_minute"]),
)
state = _load_json()
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 = _active_grant_seconds(state, user_id, now)
return allowance_seconds, usage_seconds, windows, grant_seconds
def get_remaining_grant_seconds(user_id: int) -> int:
with _lock:
state = _load_json()
return _active_grant_seconds(
state,
user_id,
datetime.now().isoformat(),
)
def add_grant(user_id: int, seconds: int):
with _lock:
state = _load_json()
grant_id = int(state["next_ids"]["grant"])
state["next_ids"]["grant"] = grant_id + 1
state["temporary_grants"].append({
"id": grant_id,
"user_id": int(user_id),
"seconds": int(seconds),
"remaining_seconds": int(seconds),
"created_at": datetime.now().isoformat(timespec="seconds"),
"expires_at": None,
"consumed": False,
})
_save_json(state)
return grant_id
def consume_grant_seconds(user_id: int, seconds: int):
if seconds <= 0:
return
with _lock:
state = _load_json()
now = datetime.now().isoformat()
remaining = int(seconds)
for grant in state["temporary_grants"]:
if remaining <= 0:
break
if int(grant["user_id"]) != int(user_id):
continue
if grant.get("consumed", False):
continue
if int(grant["remaining_seconds"]) <= 0:
continue
if (
grant.get("expires_at") is not None
and grant["expires_at"] <= now
):
continue
available = int(grant["remaining_seconds"])
consumed = min(available, remaining)
new_remaining = available - consumed
grant["remaining_seconds"] = new_remaining
grant["consumed"] = new_remaining <= 0
remaining -= consumed
_save_json(state)
def record_usage(user_id: int, seconds: int, usage_date: date | None = None):
if seconds <= 0:
return
with _lock:
state = _load_json()
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()
grants = [
dict(grant)
for grant in state["temporary_grants"]
if int(grant["user_id"]) == int(user_id)
]
grants.sort(key=lambda item: int(item["id"]), reverse=True)
return grants[:limit]
def record_event(user_id, event_type: str, details: str = ""):
with _lock:
state = _load_json()
state["events"].append({
"user_id": int(user_id) if user_id is not None else None,
"event_type": event_type,
"details": details,
"created_at": datetime.now().isoformat(timespec="seconds"),
})
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],
+5
View File
@@ -0,0 +1,5 @@
version: 1
auth:
pam_service: login
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": []
}
+285 -12
View File
@@ -2,42 +2,281 @@
set -e
# ============================================================
# Linux Parental Control - Installer
# ============================================================
if [ "$EUID" -ne 0 ]; then
echo "Please run with sudo"
echo "Please run with sudo:"
echo " sudo ./install.sh"
exit 1
fi
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
echo
echo "=================================="
echo "Linux Parental Control Installer"
echo "=================================="
echo
echo "Installing from:"
echo "$INSTALL_DIR"
echo
# ============================================================
# Detect Linux distribution
# ============================================================
if [ -f /etc/os-release ]; then
. /etc/os-release
else
echo "ERROR: Cannot determine Linux distribution."
exit 1
fi
echo "Detected:"
echo " Distribution: ${PRETTY_NAME:-Unknown}"
echo
# ============================================================
# Detect package manager
# ============================================================
PACKAGE_MANAGER=""
if command -v pacman >/dev/null 2>&1; then
PACKAGE_MANAGER="pacman"
elif command -v apt-get >/dev/null 2>&1; then
PACKAGE_MANAGER="apt"
elif command -v dnf >/dev/null 2>&1; then
PACKAGE_MANAGER="dnf"
elif command -v zypper >/dev/null 2>&1; then
PACKAGE_MANAGER="zypper"
elif command -v apk >/dev/null 2>&1; then
PACKAGE_MANAGER="apk"
elif command -v xbps-install >/dev/null 2>&1; then
PACKAGE_MANAGER="xbps"
else
echo "ERROR: Unsupported Linux distribution."
echo
echo "Supported package managers:"
echo " pacman"
echo " apt"
echo " dnf"
echo " zypper"
echo " apk"
echo " xbps"
exit 1
fi
echo "Package manager:"
echo " $PACKAGE_MANAGER"
echo
# ============================================================
# Check systemd
# ============================================================
if ! command -v systemctl >/dev/null 2>&1; then
echo "ERROR: systemd is required."
echo
echo "This installer installs a systemd service."
exit 1
fi
if [ ! -d /run/systemd/system ]; then
echo "ERROR: systemd is not currently running."
echo
echo "This installer requires a running systemd system."
exit 1
fi
echo "systemd detected."
echo
# ============================================================
# Install system dependencies
# ============================================================
echo "[1/5] Installing dependencies"
pacman -S --needed --noconfirm python python-pip
case "$PACKAGE_MANAGER" in
pacman)
# 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
;;
apt)
apt-get update
DEBIAN_FRONTEND=noninteractive \
apt-get install -y \
python3 \
python3-pip \
python3-venv
;;
dnf)
dnf install -y \
python3 \
python3-pip
;;
zypper)
zypper --non-interactive install \
python3 \
python3-pip
;;
apk)
apk add \
python3 \
py3-pip \
py3-virtualenv
;;
xbps)
xbps-install -Sy \
python3 \
python3-pip
;;
esac
# ============================================================
# Verify Python
# ============================================================
if ! command -v python3 >/dev/null 2>&1; then
echo "ERROR: python3 was not installed."
exit 1
fi
PYTHON="$(command -v python3)"
echo
echo "Python:"
"$PYTHON" --version
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
# ============================================================
echo "[2/5] Creating virtual environment"
if [ ! -d "$INSTALL_DIR/.venv" ]; then
python -m venv "$INSTALL_DIR/.venv"
"$PYTHON" -m venv "$INSTALL_DIR/.venv"
else
echo "Virtual environment already exists."
fi
# ============================================================
# Install Python packages
# ============================================================
echo
echo "[3/5] Installing Python packages"
"$INSTALL_DIR/.venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt"
"$INSTALL_DIR/.venv/bin/python" -m pip install \
-r "$INSTALL_DIR/requirements.txt"
# ============================================================
# Remove legacy SQLite storage
# ============================================================
LEGACY_DB="$INSTALL_DIR/data/parental-control.db"
if [ -f "$LEGACY_DB" ]; then
echo
echo "Removing legacy SQLite database:"
echo " $LEGACY_DB"
rm -f "$LEGACY_DB"
fi
mkdir -p "$INSTALL_DIR/data"
chmod 700 "$INSTALL_DIR/data"
# Keep the configuration and runtime state readable/writable only by root.
[ -f "$INSTALL_DIR/data/config.yaml" ] && chmod 600 "$INSTALL_DIR/data/config.yaml"
[ -f "$INSTALL_DIR/data/state.json" ] && chmod 600 "$INSTALL_DIR/data/state.json"
# ============================================================
# Install systemd service
# ============================================================
echo
echo "[4/5] Installing systemd service"
sed \
"s|%INSTALL_DIR%|$INSTALL_DIR|g" \
"$INSTALL_DIR/parental-control.service" \
> /etc/systemd/system/parental-control.service
SERVICE_FILE="/etc/systemd/system/parental-control.service"
SECRET_DIR="/etc/parental-control"
SECRET_FILE="$SECRET_DIR/session-secret"
SECRET_ENV_FILE="$SECRET_DIR/session-secret.env"
install -d -m 700 "$SECRET_DIR"
if [ ! -s "$SECRET_FILE" ]; then
"$INSTALL_DIR/.venv/bin/python" -c \
'import secrets; print(secrets.token_urlsafe(48))' \
> "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
fi
# systemd reads the environment file; the raw secret is kept separately
# so it is easy to rotate without changing the service definition.
printf 'PARENTAL_CONTROL_SESSION_SECRET=%s\n' "$(cat "$SECRET_FILE")" > "$SECRET_ENV_FILE"
chmod 600 "$SECRET_ENV_FILE"
sed \
"s|%INSTALL_DIR%|$INSTALL_DIR|g" \
"$INSTALL_DIR/parental-control.service" \
> "$SERVICE_FILE"
# ============================================================
# Enable and start service
# ============================================================
systemctl daemon-reload
@@ -46,13 +285,47 @@ systemctl enable parental-control.service
systemctl restart parental-control.service
# ============================================================
# Verify service
# ============================================================
echo
echo "[5/5] Verifying installation"
if systemctl is-active --quiet parental-control.service; then
SERVICE_STATUS="running"
else
SERVICE_STATUS="FAILED"
fi
# ============================================================
# Result
# ============================================================
echo
echo "=================================="
echo "Parental Control installed"
echo "=================================="
echo
echo "Distribution:"
echo " ${PRETTY_NAME:-Unknown}"
echo
echo "Python:"
"$INSTALL_DIR/.venv/bin/python" --version
echo
echo "Installation directory:"
echo " $INSTALL_DIR"
echo
echo "Service:"
echo " $SERVICE_STATUS"
echo
echo "Admin panel:"
echo "http://127.0.0.1:8765/admin"
echo " http://127.0.0.1:8765/admin"
echo
echo "Service commands:"
echo " sudo systemctl status parental-control"
echo " sudo systemctl restart parental-control"
echo " sudo journalctl -u parental-control -f"
echo
echo "Service status:"
echo "systemctl status parental-control"
echo "=================================="
+5
View File
@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.pyc
data/state.json
data/*.db
+1
View File
@@ -9,6 +9,7 @@ WorkingDirectory=%INSTALL_DIR%
Environment="PATH=%INSTALL_DIR%/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin"
Environment="PARENTAL_CONTROL_ENFORCEMENT=1"
EnvironmentFile=-/etc/parental-control/session-secret.env
ExecStart=%INSTALL_DIR%/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8765
+4
View File
@@ -7,10 +7,14 @@ h11==0.16.0
idna==3.19
Jinja2==3.1.6
MarkupSafe==3.0.3
python-pam>=2.0.2
pydantic==2.13.5
pydantic_core==2.46.5
PyYAML>=6.0
python-multipart==0.0.32
starlette==1.6.0
typing-inspection==0.4.4
typing_extensions==4.16.0
uvicorn==0.53.0
itsdangerous>=2.2.0
itsdangerous
+66 -393
View File
@@ -1,435 +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>
<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 }}">
<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"
>
<button
class="button-danger"
type="submit"
>
Delete
</button>
<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" 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="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>
+35
View File
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Parental Control Login</title>
<style>
* { box-sizing: border-box; }
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>
<div class="card">
<h1>Parental Control</h1>
<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" 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>
</div>
</body>
</html>
+250 -766
View File
File diff suppressed because it is too large Load Diff