added graps
This commit is contained in:
+17
-12
@@ -1,31 +1,36 @@
|
|||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*.pyo
|
||||||
|
|
||||||
# Virtual environments
|
# Virtual environment
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
env/
|
env/
|
||||||
|
|
||||||
# Environment / secrets
|
# Python tooling
|
||||||
.env
|
.pytest_cache/
|
||||||
.env.*
|
.mypy_cache/
|
||||||
!.env.example
|
.ruff_cache/
|
||||||
|
|
||||||
# Runtime state
|
# Runtime/application state
|
||||||
data/state.json
|
data/state.json
|
||||||
|
|
||||||
|
# Local configuration
|
||||||
|
data/config.yaml
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
# IDE / editors
|
# Environment/secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.secret
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# Editor/OS files
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS files
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
|
||||||
|
|||||||
@@ -1,129 +1,108 @@
|
|||||||
# Linux Parental Control
|
# Linux Parental Control
|
||||||
|
|
||||||
A Linux parental-control system for managing Linux user access, daily time allowances, access windows, temporary grants, and automatic session enforcement.
|
A Linux parental-control system for managing Linux user access, daily time allowances, access windows, temporary grants, usage history, and automatic session enforcement.
|
||||||
|
|
||||||
> **Status:** Early development
|
|
||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
SQLite has been removed.
|
SQLite has been removed.
|
||||||
|
|
||||||
The application now uses two files under `data/`:
|
The application uses two files under `data/`:
|
||||||
|
|
||||||
- `config.yaml` — users, daily allowances, access windows, and authentication configuration.
|
- `config.yaml` — users, daily allowances, access windows, and PAM configuration.
|
||||||
- `state.json` — daily usage, temporary grants, and a bounded event history.
|
- `state.json` — daily usage, temporary grants, event history, and ID counters.
|
||||||
|
|
||||||
The application never creates or opens `data/parental-control.db`.
|
The application never creates or opens `data/parental-control.db`.
|
||||||
|
|
||||||
`config.yaml` and `state.json` are written atomically and are intended to be root-readable only.
|
|
||||||
|
|
||||||
## Web authentication
|
## Web authentication
|
||||||
|
|
||||||
The administration web interface is protected by **PAM**.
|
The administration web interface uses Linux PAM for password authentication and a Linux group for authorization.
|
||||||
|
|
||||||
Sign in at:
|
The default PAM settings are:
|
||||||
|
|
||||||
```text
|
|
||||||
http://127.0.0.1:8765/admin
|
|
||||||
```
|
|
||||||
|
|
||||||
Use an existing Linux username and its Linux password. The password is passed to PAM for authentication and is not stored by this application.
|
|
||||||
|
|
||||||
The PAM service defaults to:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
auth:
|
auth:
|
||||||
pam_service: login
|
pam_service: login
|
||||||
|
pam_group: pam
|
||||||
```
|
```
|
||||||
|
|
||||||
If your distribution uses a different PAM service, change `pam_service` in `data/config.yaml`.
|
The installer creates the `pam` group and adds `root` to it. Any Linux account that successfully authenticates through the configured PAM service must also belong to this group to access `/admin`.
|
||||||
|
|
||||||
### Restricting who can use the web panel
|
To grant another administrator access:
|
||||||
|
|
||||||
By default, any Linux account that successfully authenticates through PAM can access the web panel.
|
```bash
|
||||||
|
sudo usermod -aG pam username
|
||||||
For a restricted administration panel, edit `data/config.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
auth:
|
|
||||||
pam_service: login
|
|
||||||
admin_users:
|
|
||||||
- youradminuser
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Do **not** add a user controlled by the parental-control enforcement system to `admin_users`, because account locking is performed with `passwd`.
|
To remove access:
|
||||||
|
|
||||||
The web session is signed with a randomly generated secret stored in:
|
```bash
|
||||||
|
sudo gpasswd -d username pam
|
||||||
```text
|
|
||||||
/etc/parental-control/session-secret
|
|
||||||
/etc/parental-control/session-secret.env
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Features
|
The application re-checks group membership on each request, so a removed member cannot continue using an existing session.
|
||||||
|
|
||||||
- Per-user daily time allowances
|
## Adding parental-control users
|
||||||
- Different allowances for each day of the week
|
|
||||||
- Multiple access windows per day
|
The web interface provides a drop-down containing eligible regular Linux accounts that are not already configured.
|
||||||
- Temporary time grants
|
|
||||||
- Usage tracking
|
The selector excludes:
|
||||||
- Automatic session termination
|
|
||||||
- Automatic account locking
|
- `root` / UID 0 accounts.
|
||||||
- Automatic account unlocking
|
- System accounts below the configured `UID_MIN`.
|
||||||
- Reboot-safe enforcement
|
- Accounts in the standard `wheel`, `sudo`, or `root` groups.
|
||||||
- PAM-authenticated web administration
|
|
||||||
- YAML configuration
|
This is intended to prevent the administrator account from accidentally being selected for parental enforcement.
|
||||||
- JSON runtime state
|
|
||||||
- systemd service support
|
## Policy behavior
|
||||||
|
|
||||||
|
Daily allowance and access windows are independent configuration items and may be created in any order.
|
||||||
|
|
||||||
|
For a normal daily allowance:
|
||||||
|
|
||||||
|
- If a day has no access window, there is no time-of-day restriction for that day.
|
||||||
|
- If a day has one or more access windows, normal allowance access is permitted only while the current time is inside at least one configured window.
|
||||||
|
- The daily allowance still limits the total normal usage for that day.
|
||||||
|
- A temporary grant overrides the normal allowance and access-window restriction.
|
||||||
|
|
||||||
|
The scheduler re-reads the current YAML/JSON state every enforcement cycle, so changing an allowance before or after a window produces the same final policy.
|
||||||
|
|
||||||
|
## Usage graph
|
||||||
|
|
||||||
|
Each managed user has a 14-day usage view showing:
|
||||||
|
|
||||||
|
- Total usage over the last 14 calendar days.
|
||||||
|
- Usage today.
|
||||||
|
- Remaining allowance today.
|
||||||
|
- A daily graph comparing recorded usage with the configured daily allowance.
|
||||||
|
|
||||||
|
Usage data is stored in `state.json` and survives service restarts.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
The application targets Linux systems using `systemd`.
|
The application targets Linux systems using `systemd`.
|
||||||
|
|
||||||
You need:
|
You need Linux, Python 3, Python virtual-environment support, pip, systemd, PAM, `sudo`, `passwd`, `loginctl`, and the standard user/group management commands.
|
||||||
|
|
||||||
- Linux
|
The enforcement service runs as `root` because it manages other Linux users and their sessions.
|
||||||
- Python 3
|
|
||||||
- `python-venv`
|
|
||||||
- `pip`
|
|
||||||
- `systemd`
|
|
||||||
- PAM
|
|
||||||
- `sudo`
|
|
||||||
- `passwd`
|
|
||||||
- `loginctl`
|
|
||||||
- Git
|
|
||||||
|
|
||||||
The enforcement service requires **root privileges** because it manages other Linux users and their sessions.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Clone the repository:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.shihaam.dev/Alsan/linux-user-timer.git
|
git clone https://git.shihaam.dev/Alsan/linux-user-timer.git
|
||||||
cd linux-user-timer
|
cd linux-user-timer
|
||||||
```
|
|
||||||
|
|
||||||
Make the installer executable:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod +x install.sh
|
chmod +x install.sh
|
||||||
```
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ./install.sh
|
sudo ./install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer:
|
The Arch Linux installer intentionally runs:
|
||||||
|
|
||||||
1. Installs Python dependencies including PyYAML and python-pam.
|
```bash
|
||||||
2. Creates the Python virtual environment.
|
pacman -S --needed python python-pip
|
||||||
3. Removes the legacy `data/parental-control.db` if it exists.
|
```
|
||||||
4. Initializes `config.yaml` and `state.json`.
|
|
||||||
5. Generates a random web-session secret.
|
It does **not** run `pacman -Syu`, so installing this application does not trigger a full Arch system upgrade.
|
||||||
6. Installs and starts the systemd service.
|
|
||||||
|
The installer also creates the `pam` group, adds `root`, creates the Python virtual environment, installs the application dependencies, removes the old SQLite database if present, creates the systemd service, and starts it.
|
||||||
|
|
||||||
## Service commands
|
## Service commands
|
||||||
|
|
||||||
@@ -133,6 +112,12 @@ sudo systemctl restart parental-control
|
|||||||
sudo journalctl -u parental-control -f
|
sudo journalctl -u parental-control -f
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The administration panel is available at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8765/admin
|
||||||
|
```
|
||||||
|
|
||||||
## Reverse proxy / HTTPS
|
## Reverse proxy / HTTPS
|
||||||
|
|
||||||
The application listens on:
|
The application listens on:
|
||||||
@@ -141,16 +126,14 @@ The application listens on:
|
|||||||
127.0.0.1:8765
|
127.0.0.1:8765
|
||||||
```
|
```
|
||||||
|
|
||||||
Put it behind your existing Nginx/Apache/reverse proxy if you want remote access.
|
Put it behind your existing Nginx/Apache/reverse proxy if remote access is required.
|
||||||
|
|
||||||
When HTTPS is provided directly to users, set:
|
For HTTPS-only session cookies, set this in the systemd service:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
Environment="PARENTAL_CONTROL_HTTPS_ONLY=1"
|
Environment="PARENTAL_CONTROL_HTTPS_ONLY=1"
|
||||||
```
|
```
|
||||||
|
|
||||||
in the systemd service. This marks the session cookie as HTTPS-only.
|
## Security note
|
||||||
|
|
||||||
## Important security note
|
This application controls Linux accounts and runs as root. Do not expose port `8765` directly to an untrusted network. Prefer localhost binding with an HTTPS reverse proxy and appropriate firewall rules.
|
||||||
|
|
||||||
This application controls Linux accounts and runs as root. Do not expose port `8765` directly to an untrusted network. Prefer binding it to localhost and placing it behind an HTTPS reverse proxy with appropriate firewall rules.
|
|
||||||
|
|||||||
+9
-7
@@ -3,9 +3,6 @@ import subprocess
|
|||||||
|
|
||||||
from .storage import (
|
from .storage import (
|
||||||
get_user_policy,
|
get_user_policy,
|
||||||
get_remaining_grant_seconds,
|
|
||||||
consume_grant_seconds,
|
|
||||||
record_usage,
|
|
||||||
record_event,
|
record_event,
|
||||||
list_users,
|
list_users,
|
||||||
)
|
)
|
||||||
@@ -40,6 +37,7 @@ def current_time():
|
|||||||
|
|
||||||
|
|
||||||
def is_inside_window(windows, minute: int) -> bool:
|
def is_inside_window(windows, minute: int) -> bool:
|
||||||
|
# No configured windows means that no time-of-day restriction exists.
|
||||||
if not windows:
|
if not windows:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -57,14 +55,17 @@ def evaluate_user(user_id: int, username: str):
|
|||||||
usage_seconds,
|
usage_seconds,
|
||||||
windows,
|
windows,
|
||||||
grant_seconds,
|
grant_seconds,
|
||||||
) = get_user_policy(user_id, weekday)
|
) = get_user_policy(user_id, weekday, now.date())
|
||||||
|
|
||||||
inside_window = is_inside_window(windows, minute)
|
inside_window = is_inside_window(windows, minute)
|
||||||
allowance_remaining = max(0, allowance_seconds - usage_seconds)
|
allowance_remaining = max(0, allowance_seconds - usage_seconds)
|
||||||
total_remaining = allowance_remaining + grant_seconds
|
|
||||||
logged_in = user_has_session(username)
|
logged_in = user_has_session(username)
|
||||||
|
|
||||||
allowed_by_schedule = inside_window and allowance_remaining > 0
|
# A user's normal allowance is always constrained by the configured
|
||||||
|
# access window (when one exists). A temporary grant bypasses the window
|
||||||
|
# and normal allowance by design.
|
||||||
|
normal_access_available = allowance_remaining > 0
|
||||||
|
allowed_by_schedule = inside_window and normal_access_available
|
||||||
allowed_by_grant = grant_seconds > 0
|
allowed_by_grant = grant_seconds > 0
|
||||||
should_allow = allowed_by_schedule or allowed_by_grant
|
should_allow = allowed_by_schedule or allowed_by_grant
|
||||||
|
|
||||||
@@ -104,12 +105,13 @@ def evaluate_user(user_id: int, username: str):
|
|||||||
"weekday": weekday,
|
"weekday": weekday,
|
||||||
"minute": minute,
|
"minute": minute,
|
||||||
"inside_window": inside_window,
|
"inside_window": inside_window,
|
||||||
|
"has_configured_windows": bool(windows),
|
||||||
"logged_in": logged_in,
|
"logged_in": logged_in,
|
||||||
"allowance_seconds": allowance_seconds,
|
"allowance_seconds": allowance_seconds,
|
||||||
"usage_seconds": usage_seconds,
|
"usage_seconds": usage_seconds,
|
||||||
"allowance_remaining": allowance_remaining,
|
"allowance_remaining": allowance_remaining,
|
||||||
"grant_seconds": grant_seconds,
|
"grant_seconds": grant_seconds,
|
||||||
"total_remaining": total_remaining,
|
"normal_access_available": normal_access_available,
|
||||||
"allowed": should_allow,
|
"allowed": should_allow,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+52
-10
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -23,9 +24,12 @@ from .storage import (
|
|||||||
add_grant,
|
add_grant,
|
||||||
list_grants,
|
list_grants,
|
||||||
is_admin_allowed,
|
is_admin_allowed,
|
||||||
|
get_usage_history,
|
||||||
)
|
)
|
||||||
from .users import (
|
from .users import (
|
||||||
linux_user_exists,
|
linux_user_exists,
|
||||||
|
is_non_root_user,
|
||||||
|
list_available_users,
|
||||||
lock_user,
|
lock_user,
|
||||||
unlock_user,
|
unlock_user,
|
||||||
terminate_user,
|
terminate_user,
|
||||||
@@ -34,13 +38,13 @@ from .users import (
|
|||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
SESSION_SECRET = __import__("os").environ.get(
|
SESSION_SECRET = os.environ.get(
|
||||||
"PARENTAL_CONTROL_SESSION_SECRET"
|
"PARENTAL_CONTROL_SESSION_SECRET"
|
||||||
) or secrets.token_urlsafe(32)
|
) or secrets.token_urlsafe(32)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Parental Control",
|
title="Parental Control",
|
||||||
version="0.2.0",
|
version="0.3.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -49,9 +53,7 @@ app.add_middleware(
|
|||||||
session_cookie="parental_control_session",
|
session_cookie="parental_control_session",
|
||||||
max_age=8 * 60 * 60,
|
max_age=8 * 60 * 60,
|
||||||
same_site="lax",
|
same_site="lax",
|
||||||
https_only=__import__("os").environ.get(
|
https_only=os.environ.get("PARENTAL_CONTROL_HTTPS_ONLY", "0") == "1",
|
||||||
"PARENTAL_CONTROL_HTTPS_ONLY", "0"
|
|
||||||
) == "1",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||||
@@ -83,6 +85,8 @@ def current_user(request: Request):
|
|||||||
if not username:
|
if not username:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Re-check the Linux PAM group on every request so removing an account
|
||||||
|
# from the admin group takes effect without waiting for the session TTL.
|
||||||
if not is_admin_allowed(username):
|
if not is_admin_allowed(username):
|
||||||
request.session.clear()
|
request.session.clear()
|
||||||
return None
|
return None
|
||||||
@@ -100,7 +104,6 @@ def require_web_auth(request: Request):
|
|||||||
f"/login?next={next_path}",
|
f"/login?next={next_path}",
|
||||||
status_code=303,
|
status_code=303,
|
||||||
)
|
)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -144,16 +147,19 @@ class GrantRequest(BaseModel):
|
|||||||
def root():
|
def root():
|
||||||
return {
|
return {
|
||||||
"application": "Parental Control",
|
"application": "Parental Control",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"authentication": "PAM",
|
"authentication": "PAM + pam Linux group",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/login")
|
@app.get("/login")
|
||||||
def login_page(request: Request, next: str = "/admin"):
|
def login_page(request: Request, next: str = "/admin"):
|
||||||
if current_user(request):
|
if current_user(request):
|
||||||
return RedirectResponse(next if next.startswith("/") and not next.startswith("//") else "/admin", status_code=303)
|
return RedirectResponse(
|
||||||
|
next if next.startswith("/") and not next.startswith("//") else "/admin",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
@@ -161,6 +167,7 @@ def login_page(request: Request, next: str = "/admin"):
|
|||||||
context={
|
context={
|
||||||
"next": next if next.startswith("/") else "/admin",
|
"next": next if next.startswith("/") else "/admin",
|
||||||
"error": None,
|
"error": None,
|
||||||
|
"pam_group": get_config()["auth"].get("pam_group", "pam"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -191,6 +198,7 @@ def login(
|
|||||||
context={
|
context={
|
||||||
"next": safe_next,
|
"next": safe_next,
|
||||||
"error": "Invalid Linux username or password.",
|
"error": "Invalid Linux username or password.",
|
||||||
|
"pam_group": get_config()["auth"].get("pam_group", "pam"),
|
||||||
},
|
},
|
||||||
status_code=401,
|
status_code=401,
|
||||||
)
|
)
|
||||||
@@ -201,7 +209,8 @@ def login(
|
|||||||
name="login.html",
|
name="login.html",
|
||||||
context={
|
context={
|
||||||
"next": safe_next,
|
"next": safe_next,
|
||||||
"error": "This Linux account is not allowed to access the administration panel.",
|
"error": "Your Linux account is authenticated, but it is not a member of the PAM administration group.",
|
||||||
|
"pam_group": get_config()["auth"].get("pam_group", "pam"),
|
||||||
},
|
},
|
||||||
status_code=403,
|
status_code=403,
|
||||||
)
|
)
|
||||||
@@ -236,6 +245,14 @@ def list_users_api(request: Request):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/users/{user_id}/usage")
|
||||||
|
def user_usage_api(request: Request, user_id: int, days: int = 14):
|
||||||
|
require_api_auth(request)
|
||||||
|
if get_user(user_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return get_usage_history(user_id, days)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/users/{user_id}/lock")
|
@app.post("/api/users/{user_id}/lock")
|
||||||
def manually_lock(user_id: int, request: Request):
|
def manually_lock(user_id: int, request: Request):
|
||||||
require_api_auth(request)
|
require_api_auth(request)
|
||||||
@@ -318,13 +335,21 @@ def admin_page(request: Request):
|
|||||||
if redirect:
|
if redirect:
|
||||||
return redirect
|
return redirect
|
||||||
|
|
||||||
|
configured = {user["username"] for user in list_users()}
|
||||||
|
available_users = [
|
||||||
|
user for user in list_available_users()
|
||||||
|
if user["username"] not in configured
|
||||||
|
]
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="index.html",
|
name="index.html",
|
||||||
context={
|
context={
|
||||||
"users": list_users(),
|
"users": list_users(),
|
||||||
|
"available_users": available_users,
|
||||||
"username": current_user(request),
|
"username": current_user(request),
|
||||||
"csrf_token": csrf_token(request),
|
"csrf_token": csrf_token(request),
|
||||||
|
"pam_group": get_config()["auth"].get("pam_group", "pam"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -349,6 +374,12 @@ def admin_user_page(request: Request, user_id: int):
|
|||||||
key=lambda item: (int(item["weekday"]), int(item["start_minute"])),
|
key=lambda item: (int(item["weekday"]), int(item["start_minute"])),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
usage_history = get_usage_history(user_id, 14)
|
||||||
|
total_used = sum(item["used_seconds"] for item in usage_history)
|
||||||
|
total_allowance = sum(item["allowance_seconds"] for item in usage_history)
|
||||||
|
today_used = usage_history[-1]["used_seconds"] if usage_history else 0
|
||||||
|
today_allowance = usage_history[-1]["allowance_seconds"] if usage_history else 0
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="user.html",
|
name="user.html",
|
||||||
@@ -359,6 +390,11 @@ def admin_user_page(request: Request, user_id: int):
|
|||||||
"allowances": allowances,
|
"allowances": allowances,
|
||||||
"windows": windows,
|
"windows": windows,
|
||||||
"grants": list_grants(user_id),
|
"grants": list_grants(user_id),
|
||||||
|
"usage_history": usage_history,
|
||||||
|
"total_used": total_used,
|
||||||
|
"total_allowance": total_allowance,
|
||||||
|
"today_used": today_used,
|
||||||
|
"today_allowance": today_allowance,
|
||||||
"csrf_token": csrf_token(request),
|
"csrf_token": csrf_token(request),
|
||||||
"username": current_user(request),
|
"username": current_user(request),
|
||||||
},
|
},
|
||||||
@@ -531,6 +567,12 @@ def admin_add_user(
|
|||||||
if not linux_user_exists(username):
|
if not linux_user_exists(username):
|
||||||
raise HTTPException(status_code=400, detail="Linux user does not exist")
|
raise HTTPException(status_code=400, detail="Linux user does not exist")
|
||||||
|
|
||||||
|
if not is_non_root_user(username):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Only regular non-root Linux users can be added to parental control.",
|
||||||
|
)
|
||||||
|
|
||||||
if get_user_by_username(username) is not None:
|
if get_user_by_username(username) is not None:
|
||||||
raise HTTPException(status_code=400, detail="User is already configured")
|
raise HTTPException(status_code=400, detail="User is already configured")
|
||||||
|
|
||||||
|
|||||||
+35
-108
@@ -1,156 +1,86 @@
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import date
|
||||||
|
|
||||||
from .enforcement import enforce_all_users
|
from .enforcement import enforce_all_users
|
||||||
from .storage import record_usage, get_user_policy, consume_grant_seconds
|
from .storage import get_user_policy, record_usage, consume_grant_seconds
|
||||||
|
|
||||||
|
|
||||||
CHECK_INTERVAL = 5
|
CHECK_INTERVAL = 5
|
||||||
|
|
||||||
|
|
||||||
class Scheduler:
|
class Scheduler:
|
||||||
|
def __init__(self, interval: int = CHECK_INTERVAL):
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
interval: int = CHECK_INTERVAL,
|
|
||||||
):
|
|
||||||
self.interval = interval
|
self.interval = interval
|
||||||
|
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
|
|
||||||
self._last_usage_update = {}
|
self._last_usage_update = {}
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
if self._thread is not None and self._thread.is_alive():
|
||||||
if (
|
|
||||||
self._thread is not None
|
|
||||||
and self._thread.is_alive()
|
|
||||||
):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self._stop_event.clear()
|
self._stop_event.clear()
|
||||||
|
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=self._run,
|
target=self._run,
|
||||||
name="parental-control-scheduler",
|
name="parental-control-scheduler",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
|
||||||
if self._thread is not None:
|
if self._thread is not None:
|
||||||
self._thread.join(
|
self._thread.join(timeout=self.interval + 2)
|
||||||
timeout=self.interval + 2
|
|
||||||
)
|
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
|
|
||||||
# Evaluate immediately when the
|
|
||||||
# application starts.
|
|
||||||
self._tick()
|
self._tick()
|
||||||
|
while not self._stop_event.wait(self.interval):
|
||||||
while not self._stop_event.wait(
|
|
||||||
self.interval
|
|
||||||
):
|
|
||||||
self._tick()
|
self._tick()
|
||||||
|
|
||||||
def _tick(self):
|
def _tick(self):
|
||||||
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
|
|
||||||
results = enforce_all_users()
|
results = enforce_all_users()
|
||||||
|
|
||||||
for result in results:
|
for result in results:
|
||||||
|
|
||||||
user_id = result["user_id"]
|
user_id = result["user_id"]
|
||||||
|
previous = self._last_usage_update.get(user_id)
|
||||||
|
|
||||||
if not result["logged_in"]:
|
if previous is not None:
|
||||||
self._last_usage_update.pop(
|
elapsed = max(0, int(now - previous["monotonic"]))
|
||||||
user_id,
|
if elapsed > 0 and previous["allowed"] and previous["logged_in"]:
|
||||||
None,
|
self._record_allowed_usage(
|
||||||
)
|
user_id,
|
||||||
continue
|
elapsed,
|
||||||
|
previous["weekday"],
|
||||||
|
previous["date"],
|
||||||
|
)
|
||||||
|
|
||||||
if not result["allowed"]:
|
if result["logged_in"] and result["allowed"]:
|
||||||
self._last_usage_update.pop(
|
self._last_usage_update[user_id] = {
|
||||||
user_id,
|
"monotonic": now,
|
||||||
None,
|
"allowed": True,
|
||||||
)
|
"logged_in": True,
|
||||||
continue
|
"weekday": result["weekday"],
|
||||||
|
"date": result["timestamp"][:10],
|
||||||
previous = (
|
}
|
||||||
self._last_usage_update.get(
|
else:
|
||||||
user_id
|
self._last_usage_update.pop(user_id, None)
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self._last_usage_update[user_id] = now
|
|
||||||
|
|
||||||
if previous is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
elapsed = int(
|
|
||||||
now - previous
|
|
||||||
)
|
|
||||||
|
|
||||||
if elapsed <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
self._record_allowed_usage(
|
|
||||||
user_id,
|
|
||||||
elapsed,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _record_allowed_usage(
|
|
||||||
self,
|
|
||||||
user_id: int,
|
|
||||||
seconds: int,
|
|
||||||
):
|
|
||||||
|
|
||||||
|
def _record_allowed_usage(self, user_id: int, seconds: int, weekday: int, usage_date: str):
|
||||||
if seconds <= 0:
|
if seconds <= 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
weekday = datetime.now().weekday()
|
|
||||||
|
|
||||||
(
|
(
|
||||||
allowance_seconds,
|
allowance_seconds,
|
||||||
usage_seconds,
|
usage_seconds,
|
||||||
windows,
|
_windows,
|
||||||
grant_seconds,
|
grant_seconds,
|
||||||
) = get_user_policy(
|
) = get_user_policy(user_id, weekday)
|
||||||
user_id,
|
|
||||||
weekday,
|
|
||||||
)
|
|
||||||
|
|
||||||
allowance_remaining = max(
|
allowance_remaining = max(0, allowance_seconds - usage_seconds)
|
||||||
0,
|
normal_usage = min(seconds, allowance_remaining)
|
||||||
allowance_seconds
|
grant_usage = min(max(0, seconds - normal_usage), grant_seconds)
|
||||||
- usage_seconds,
|
total_usage = normal_usage + grant_usage
|
||||||
)
|
|
||||||
|
|
||||||
normal_usage = min(
|
|
||||||
seconds,
|
|
||||||
allowance_remaining,
|
|
||||||
)
|
|
||||||
|
|
||||||
grant_usage = (
|
|
||||||
seconds
|
|
||||||
- normal_usage
|
|
||||||
)
|
|
||||||
|
|
||||||
if grant_usage > grant_seconds:
|
|
||||||
grant_usage = grant_seconds
|
|
||||||
|
|
||||||
total_usage = (
|
|
||||||
normal_usage
|
|
||||||
+ grant_usage
|
|
||||||
)
|
|
||||||
|
|
||||||
if total_usage <= 0:
|
if total_usage <= 0:
|
||||||
return
|
return
|
||||||
@@ -158,14 +88,11 @@ class Scheduler:
|
|||||||
record_usage(
|
record_usage(
|
||||||
user_id,
|
user_id,
|
||||||
total_usage,
|
total_usage,
|
||||||
|
date.fromisoformat(usage_date),
|
||||||
)
|
)
|
||||||
|
|
||||||
if grant_usage > 0:
|
if grant_usage > 0:
|
||||||
|
consume_grant_seconds(user_id, grant_usage)
|
||||||
consume_grant_seconds(
|
|
||||||
user_id,
|
|
||||||
grant_usage,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
scheduler = Scheduler()
|
scheduler = Scheduler()
|
||||||
|
|||||||
+75
-40
@@ -1,10 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from contextlib import contextmanager
|
from datetime import datetime, date, timedelta
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -19,7 +18,7 @@ DEFAULT_CONFIG = {
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"auth": {
|
"auth": {
|
||||||
"pam_service": "login",
|
"pam_service": "login",
|
||||||
"admin_users": [],
|
"pam_group": "pam",
|
||||||
},
|
},
|
||||||
"users": [],
|
"users": [],
|
||||||
}
|
}
|
||||||
@@ -68,9 +67,13 @@ def _load_yaml():
|
|||||||
|
|
||||||
data.setdefault("version", 1)
|
data.setdefault("version", 1)
|
||||||
data.setdefault("auth", {})
|
data.setdefault("auth", {})
|
||||||
|
if not isinstance(data["auth"], dict):
|
||||||
|
raise ValueError("config.yaml auth must be an object")
|
||||||
data["auth"].setdefault("pam_service", "login")
|
data["auth"].setdefault("pam_service", "login")
|
||||||
data["auth"].setdefault("admin_users", [])
|
data["auth"].setdefault("pam_group", "pam")
|
||||||
data.setdefault("users", [])
|
data.setdefault("users", [])
|
||||||
|
if not isinstance(data["users"], list):
|
||||||
|
raise ValueError("config.yaml users must be a list")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -124,8 +127,6 @@ def initialize_storage():
|
|||||||
if not STATE_PATH.exists():
|
if not STATE_PATH.exists():
|
||||||
_save_json(DEFAULT_STATE)
|
_save_json(DEFAULT_STATE)
|
||||||
|
|
||||||
# Keep files usable after manual edits while avoiding destructive
|
|
||||||
# initialization or recreation of any database.
|
|
||||||
config = _load_yaml()
|
config = _load_yaml()
|
||||||
state = _load_json()
|
state = _load_json()
|
||||||
_save_yaml(config)
|
_save_yaml(config)
|
||||||
@@ -141,14 +142,14 @@ def get_pam_service():
|
|||||||
return get_config()["auth"].get("pam_service", "login")
|
return get_config()["auth"].get("pam_service", "login")
|
||||||
|
|
||||||
|
|
||||||
def admin_users():
|
def get_pam_group():
|
||||||
value = get_config()["auth"].get("admin_users", [])
|
return get_config()["auth"].get("pam_group", "pam")
|
||||||
return {str(item) for item in value}
|
|
||||||
|
|
||||||
|
|
||||||
def is_admin_allowed(username: str) -> bool:
|
def is_admin_allowed(username: str) -> bool:
|
||||||
allowed = admin_users()
|
"""Keep authorization in users.py so PAM group membership is system-backed."""
|
||||||
return not allowed or username in allowed
|
from .users import user_in_group
|
||||||
|
return user_in_group(username, get_pam_group())
|
||||||
|
|
||||||
|
|
||||||
def _find_user(config, user_id):
|
def _find_user(config, user_id):
|
||||||
@@ -256,7 +257,7 @@ def set_allowance(user_id: int, weekday: int, seconds: int):
|
|||||||
raise KeyError("User not found")
|
raise KeyError("User not found")
|
||||||
|
|
||||||
user.setdefault("allowances", {})
|
user.setdefault("allowances", {})
|
||||||
user["allowances"][str(weekday)] = int(seconds)
|
user["allowances"][str(weekday)] = max(0, int(seconds))
|
||||||
_save_yaml(config)
|
_save_yaml(config)
|
||||||
|
|
||||||
|
|
||||||
@@ -298,7 +299,26 @@ def delete_window(window_id: int):
|
|||||||
return int(owner["id"])
|
return int(owner["id"])
|
||||||
|
|
||||||
|
|
||||||
def get_user_policy(user_id: int, weekday: int):
|
def _active_grant_seconds(state, user_id: int, now_iso: str) -> int:
|
||||||
|
return sum(
|
||||||
|
int(grant["remaining_seconds"])
|
||||||
|
for grant in state["temporary_grants"]
|
||||||
|
if int(grant["user_id"]) == int(user_id)
|
||||||
|
and not grant.get("consumed", False)
|
||||||
|
and int(grant.get("remaining_seconds", 0)) > 0
|
||||||
|
and (
|
||||||
|
grant.get("expires_at") is None
|
||||||
|
or grant["expires_at"] > now_iso
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_policy(user_id: int, weekday: int, on_date: date | None = None):
|
||||||
|
"""Return the complete policy for a specific weekday/date.
|
||||||
|
|
||||||
|
Configuration is always read from the current files, so allowance and
|
||||||
|
access-window changes are order-independent.
|
||||||
|
"""
|
||||||
with _lock:
|
with _lock:
|
||||||
config = _load_yaml()
|
config = _load_yaml()
|
||||||
user = _find_user(config, user_id)
|
user = _find_user(config, user_id)
|
||||||
@@ -318,22 +338,14 @@ def get_user_policy(user_id: int, weekday: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
state = _load_json()
|
state = _load_json()
|
||||||
today = datetime.now().date().isoformat()
|
target_date = on_date or datetime.now().date()
|
||||||
|
today = target_date.isoformat()
|
||||||
usage_seconds = int(
|
usage_seconds = int(
|
||||||
state["usage"].get(f"{int(user_id)}:{today}", 0)
|
state["usage"].get(f"{int(user_id)}:{today}", 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
now = datetime.now().isoformat()
|
now = datetime.now().isoformat()
|
||||||
grant_seconds = sum(
|
grant_seconds = _active_grant_seconds(state, user_id, now)
|
||||||
int(grant["remaining_seconds"])
|
|
||||||
for grant in state["temporary_grants"]
|
|
||||||
if int(grant["user_id"]) == int(user_id)
|
|
||||||
and not grant.get("consumed", False)
|
|
||||||
and (
|
|
||||||
grant.get("expires_at") is None
|
|
||||||
or grant["expires_at"] > now
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return allowance_seconds, usage_seconds, windows, grant_seconds
|
return allowance_seconds, usage_seconds, windows, grant_seconds
|
||||||
|
|
||||||
@@ -341,17 +353,10 @@ def get_user_policy(user_id: int, weekday: int):
|
|||||||
def get_remaining_grant_seconds(user_id: int) -> int:
|
def get_remaining_grant_seconds(user_id: int) -> int:
|
||||||
with _lock:
|
with _lock:
|
||||||
state = _load_json()
|
state = _load_json()
|
||||||
now = datetime.now().isoformat()
|
return _active_grant_seconds(
|
||||||
return sum(
|
state,
|
||||||
int(grant["remaining_seconds"])
|
user_id,
|
||||||
for grant in state["temporary_grants"]
|
datetime.now().isoformat(),
|
||||||
if int(grant["user_id"]) == int(user_id)
|
|
||||||
and not grant.get("consumed", False)
|
|
||||||
and int(grant["remaining_seconds"]) > 0
|
|
||||||
and (
|
|
||||||
grant.get("expires_at") is None
|
|
||||||
or grant["expires_at"] > now
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -407,18 +412,49 @@ def consume_grant_seconds(user_id: int, seconds: int):
|
|||||||
_save_json(state)
|
_save_json(state)
|
||||||
|
|
||||||
|
|
||||||
def record_usage(user_id: int, seconds: int):
|
def record_usage(user_id: int, seconds: int, usage_date: date | None = None):
|
||||||
if seconds <= 0:
|
if seconds <= 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
with _lock:
|
with _lock:
|
||||||
state = _load_json()
|
state = _load_json()
|
||||||
today = datetime.now().date().isoformat()
|
target_date = usage_date or datetime.now().date()
|
||||||
key = f"{int(user_id)}:{today}"
|
key = f"{int(user_id)}:{target_date.isoformat()}"
|
||||||
state["usage"][key] = int(state["usage"].get(key, 0)) + int(seconds)
|
state["usage"][key] = int(state["usage"].get(key, 0)) + int(seconds)
|
||||||
_save_json(state)
|
_save_json(state)
|
||||||
|
|
||||||
|
|
||||||
|
def get_usage_history(user_id: int, days: int = 14):
|
||||||
|
days = max(1, min(int(days), 90))
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
config = _load_yaml()
|
||||||
|
state = _load_json()
|
||||||
|
user = _find_user(config, user_id)
|
||||||
|
if user is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
today = datetime.now().date()
|
||||||
|
history = []
|
||||||
|
|
||||||
|
for offset in range(days - 1, -1, -1):
|
||||||
|
day = today - timedelta(days=offset)
|
||||||
|
weekday = day.weekday()
|
||||||
|
allowance = int(
|
||||||
|
user.get("allowances", {}).get(str(weekday), 0)
|
||||||
|
)
|
||||||
|
key = f"{int(user_id)}:{day.isoformat()}"
|
||||||
|
used = int(state["usage"].get(key, 0))
|
||||||
|
history.append({
|
||||||
|
"date": day.isoformat(),
|
||||||
|
"weekday": weekday,
|
||||||
|
"used_seconds": used,
|
||||||
|
"allowance_seconds": allowance,
|
||||||
|
})
|
||||||
|
|
||||||
|
return history
|
||||||
|
|
||||||
|
|
||||||
def list_grants(user_id: int, limit: int = 20):
|
def list_grants(user_id: int, limit: int = 20):
|
||||||
with _lock:
|
with _lock:
|
||||||
state = _load_json()
|
state = _load_json()
|
||||||
@@ -440,6 +476,5 @@ def record_event(user_id, event_type: str, details: str = ""):
|
|||||||
"details": details,
|
"details": details,
|
||||||
"created_at": datetime.now().isoformat(timespec="seconds"),
|
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
})
|
})
|
||||||
# Keep the state file bounded.
|
|
||||||
state["events"] = state["events"][-2000:]
|
state["events"] = state["events"][-2000:]
|
||||||
_save_json(state)
|
_save_json(state)
|
||||||
|
|||||||
+114
@@ -1,5 +1,7 @@
|
|||||||
|
import grp
|
||||||
import pwd
|
import pwd
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from typing import List, Dict
|
||||||
|
|
||||||
|
|
||||||
def linux_user_exists(username: str) -> bool:
|
def linux_user_exists(username: str) -> bool:
|
||||||
@@ -14,6 +16,118 @@ def get_uid(username: str) -> int:
|
|||||||
return pwd.getpwnam(username).pw_uid
|
return pwd.getpwnam(username).pw_uid
|
||||||
|
|
||||||
|
|
||||||
|
def _login_def_value(name: str, default: int) -> int:
|
||||||
|
"""Read an integer value from /etc/login.defs."""
|
||||||
|
try:
|
||||||
|
with open("/etc/login.defs", "r", encoding="utf-8") as handle:
|
||||||
|
for line in handle:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2 and parts[0] == name:
|
||||||
|
value = int(parts[1])
|
||||||
|
if value > 0:
|
||||||
|
return value
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _uid_min() -> int:
|
||||||
|
return _login_def_value("UID_MIN", 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _uid_max() -> int:
|
||||||
|
return _login_def_value("UID_MAX", 60000)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_interactive_shell(shell: str) -> bool:
|
||||||
|
"""Exclude service accounts that cannot be used for interactive logins."""
|
||||||
|
shell = (shell or "").strip().lower()
|
||||||
|
if not shell:
|
||||||
|
return False
|
||||||
|
return not shell.endswith(("/nologin", "/false"))
|
||||||
|
|
||||||
|
|
||||||
|
def _supplementary_groups(username: str) -> set[str]:
|
||||||
|
groups = set()
|
||||||
|
try:
|
||||||
|
user = pwd.getpwnam(username)
|
||||||
|
except KeyError:
|
||||||
|
return groups
|
||||||
|
|
||||||
|
try:
|
||||||
|
groups.add(grp.getgrgid(user.pw_gid).gr_name)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for group in grp.getgrall():
|
||||||
|
if username in group.gr_mem:
|
||||||
|
groups.add(group.gr_name)
|
||||||
|
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def has_root_privileges(username: str) -> bool:
|
||||||
|
"""Best-effort detection for accounts with ordinary root-style group access."""
|
||||||
|
try:
|
||||||
|
user = pwd.getpwnam(username)
|
||||||
|
except KeyError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if user.pw_uid == 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
groups = _supplementary_groups(username)
|
||||||
|
return bool(groups.intersection({"root", "wheel", "sudo"}))
|
||||||
|
|
||||||
|
|
||||||
|
def is_non_root_user(username: str) -> bool:
|
||||||
|
"""Return True for regular non-root accounts suitable for parental control."""
|
||||||
|
try:
|
||||||
|
user = pwd.getpwnam(username)
|
||||||
|
except KeyError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if user.pw_uid < _uid_min() or user.pw_uid > _uid_max():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if username in {"nobody", "nfsnobody"}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not _is_interactive_shell(user.pw_shell):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return not has_root_privileges(username)
|
||||||
|
|
||||||
|
|
||||||
|
def list_available_users() -> List[Dict[str, str]]:
|
||||||
|
"""Return regular interactive users that can be selected for parental control."""
|
||||||
|
users = []
|
||||||
|
|
||||||
|
for user in pwd.getpwall():
|
||||||
|
if not is_non_root_user(user.pw_name):
|
||||||
|
continue
|
||||||
|
|
||||||
|
users.append({"username": user.pw_name})
|
||||||
|
|
||||||
|
return sorted(users, key=lambda item: item["username"].lower())
|
||||||
|
|
||||||
|
|
||||||
|
def user_in_group(username: str, group_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
group = grp.getgrnam(group_name)
|
||||||
|
user = pwd.getpwnam(username)
|
||||||
|
except KeyError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return (
|
||||||
|
username in group.gr_mem
|
||||||
|
or user.pw_gid == group.gr_gid
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_locked(username: str) -> bool:
|
def is_locked(username: str) -> bool:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["passwd", "-S", username],
|
["passwd", "-S", username],
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
version: 1
|
version: 1
|
||||||
auth:
|
auth:
|
||||||
pam_service: login
|
pam_service: login
|
||||||
admin_users:
|
pam_group: pam
|
||||||
- root
|
|
||||||
users: []
|
users: []
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"next_ids": {
|
||||||
|
"user": 3,
|
||||||
|
"window": 2,
|
||||||
|
"grant": 1
|
||||||
|
},
|
||||||
|
"usage": {},
|
||||||
|
"temporary_grants": [],
|
||||||
|
"events": []
|
||||||
|
}
|
||||||
+24
-5
@@ -113,7 +113,9 @@ echo "[1/5] Installing dependencies"
|
|||||||
case "$PACKAGE_MANAGER" in
|
case "$PACKAGE_MANAGER" in
|
||||||
|
|
||||||
pacman)
|
pacman)
|
||||||
pacman -Syu --needed --noconfirm \
|
# Do NOT use -Syu here. This installer must not upgrade the whole
|
||||||
|
# Arch system; it only installs the packages required by this app.
|
||||||
|
pacman -S --needed --noconfirm \
|
||||||
python \
|
python \
|
||||||
python-pip
|
python-pip
|
||||||
;;
|
;;
|
||||||
@@ -156,7 +158,6 @@ case "$PACKAGE_MANAGER" in
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Verify Python
|
# Verify Python
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -174,6 +175,27 @@ echo "Python:"
|
|||||||
echo
|
echo
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Prepare PAM administration group
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
echo "Preparing PAM administration group"
|
||||||
|
|
||||||
|
if ! getent group pam >/dev/null 2>&1; then
|
||||||
|
echo "Creating system group: pam"
|
||||||
|
groupadd --system pam
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! id -nG root | tr " " "\n" | grep -qx "pam"; then
|
||||||
|
echo "Adding root to group: pam"
|
||||||
|
usermod -aG pam root
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "PAM group:"
|
||||||
|
getent group pam
|
||||||
|
echo
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Create virtual environment
|
# Create virtual environment
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -194,9 +216,6 @@ fi
|
|||||||
echo
|
echo
|
||||||
echo "[3/5] Installing Python packages"
|
echo "[3/5] Installing Python packages"
|
||||||
|
|
||||||
"$INSTALL_DIR/.venv/bin/python" -m pip install \
|
|
||||||
--upgrade pip
|
|
||||||
|
|
||||||
"$INSTALL_DIR/.venv/bin/python" -m pip install \
|
"$INSTALL_DIR/.venv/bin/python" -m pip install \
|
||||||
-r "$INSTALL_DIR/requirements.txt"
|
-r "$INSTALL_DIR/requirements.txt"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
data/state.json
|
||||||
|
data/*.db
|
||||||
@@ -11,7 +11,7 @@ Environment="PATH=%INSTALL_DIR%/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/bi
|
|||||||
Environment="PARENTAL_CONTROL_ENFORCEMENT=1"
|
Environment="PARENTAL_CONTROL_ENFORCEMENT=1"
|
||||||
EnvironmentFile=-/etc/parental-control/session-secret.env
|
EnvironmentFile=-/etc/parental-control/session-secret.env
|
||||||
|
|
||||||
ExecStart=%INSTALL_DIR%/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8765
|
ExecStart=%INSTALL_DIR%/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8765
|
||||||
|
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ starlette==1.6.0
|
|||||||
typing-inspection==0.4.4
|
typing-inspection==0.4.4
|
||||||
typing_extensions==4.16.0
|
typing_extensions==4.16.0
|
||||||
uvicorn==0.53.0
|
uvicorn==0.53.0
|
||||||
|
itsdangerous>=2.2.0
|
||||||
itsdangerous
|
itsdangerous
|
||||||
|
|||||||
+60
-397
@@ -1,445 +1,108 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0"
|
|
||||||
>
|
|
||||||
|
|
||||||
<title>Parental Control</title>
|
<title>Parental Control</title>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
* {
|
body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f4f5f7; color: #1f2937; }
|
||||||
box-sizing: border-box;
|
header { background: #111827; color: white; padding: 20px 30px; }
|
||||||
}
|
header h1 { margin: 0; font-size: 24px; }
|
||||||
|
main { max-width: 1100px; margin: 30px auto; padding: 0 20px; }
|
||||||
body {
|
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; gap: 15px; }
|
||||||
margin: 0;
|
.card { background: white; border-radius: 12px; padding: 20px; margin-bottom: 15px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
|
||||||
font-family:
|
.user-header { display: flex; justify-content: space-between; align-items: center; gap: 15px; flex-wrap: wrap; }
|
||||||
system-ui,
|
.username { font-size: 20px; font-weight: 600; }
|
||||||
-apple-system,
|
.muted { color: #6b7280; font-size: 14px; }
|
||||||
BlinkMacSystemFont,
|
.status { display: inline-block; padding: 5px 10px; border-radius: 20px; font-size: 13px; }
|
||||||
"Segoe UI",
|
.status-enabled { background: #dcfce7; color: #166534; }
|
||||||
sans-serif;
|
.status-locked { background: #fee2e2; color: #991b1b; }
|
||||||
|
.actions { display: flex; gap: 10px; margin-top: 15px; flex-wrap: wrap; }
|
||||||
background: #f4f5f7;
|
button, .button { border: 0; border-radius: 7px; padding: 9px 15px; cursor: pointer; font-size: 14px; text-decoration: none; display: inline-block; }
|
||||||
color: #1f2937;
|
.button-primary { background: #2563eb; color: white; }
|
||||||
}
|
.button-danger { background: #dc2626; color: white; }
|
||||||
|
.button-secondary { background: #e5e7eb; color: #111827; }
|
||||||
|
.add-user form { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||||
header {
|
input, select { border: 1px solid #d1d5db; border-radius: 7px; padding: 9px 12px; font-size: 14px; background: white; }
|
||||||
|
select { min-width: 320px; }
|
||||||
background: #111827;
|
.empty { color: #6b7280; }
|
||||||
color: white;
|
.info { margin-top: 10px; padding: 12px 14px; background: #eff6ff; border-radius: 8px; color: #1e40af; font-size: 14px; }
|
||||||
|
@media (max-width: 700px) { select { min-width: 100%; } }
|
||||||
padding: 20px 30px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
header h1 {
|
|
||||||
|
|
||||||
margin: 0;
|
|
||||||
font-size: 24px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
main {
|
|
||||||
|
|
||||||
max-width: 1100px;
|
|
||||||
|
|
||||||
margin: 30px auto;
|
|
||||||
|
|
||||||
padding: 0 20px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.topbar {
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
margin-bottom: 25px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.topbar h2 {
|
|
||||||
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.card {
|
|
||||||
|
|
||||||
background: white;
|
|
||||||
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
padding: 20px;
|
|
||||||
|
|
||||||
margin-bottom: 15px;
|
|
||||||
|
|
||||||
box-shadow:
|
|
||||||
0 2px 8px rgba(0, 0, 0, 0.08);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.user-header {
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.username {
|
|
||||||
|
|
||||||
font-size: 20px;
|
|
||||||
|
|
||||||
font-weight: 600;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.status {
|
|
||||||
|
|
||||||
display: inline-block;
|
|
||||||
|
|
||||||
padding: 5px 10px;
|
|
||||||
|
|
||||||
border-radius: 20px;
|
|
||||||
|
|
||||||
font-size: 13px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.status-enabled {
|
|
||||||
|
|
||||||
background: #dcfce7;
|
|
||||||
|
|
||||||
color: #166534;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.status-disabled {
|
|
||||||
|
|
||||||
background: #fee2e2;
|
|
||||||
|
|
||||||
color: #991b1b;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
margin-top: 15px;
|
|
||||||
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
button {
|
|
||||||
|
|
||||||
border: 0;
|
|
||||||
|
|
||||||
border-radius: 7px;
|
|
||||||
|
|
||||||
padding: 9px 15px;
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.button-primary {
|
|
||||||
|
|
||||||
background: #2563eb;
|
|
||||||
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.button-danger {
|
|
||||||
|
|
||||||
background: #dc2626;
|
|
||||||
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.button-secondary {
|
|
||||||
|
|
||||||
background: #e5e7eb;
|
|
||||||
|
|
||||||
color: #111827;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.add-user {
|
|
||||||
|
|
||||||
margin-top: 30px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.add-user form {
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
input {
|
|
||||||
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
|
|
||||||
border-radius: 7px;
|
|
||||||
|
|
||||||
padding: 9px 12px;
|
|
||||||
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
|
|
||||||
color: #6b7280;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
|
<h1>Parental Control</h1>
|
||||||
<h1>
|
<div style="margin-top:8px;font-size:14px;">
|
||||||
Parental Control
|
Signed in as <strong>{{ username }}</strong>
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div style="margin-top:8px;font-size:14px;">Signed in as <strong>{{ username }}</strong>
|
|
||||||
<form method="post" action="/logout" style="display:inline;margin-left:12px;">
|
<form method="post" action="/logout" style="display:inline;margin-left:12px;">
|
||||||
<input type="hidden" name="csrf" value="{{ csrf_token }}">
|
|
||||||
<input type="hidden" name="csrf" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf" value="{{ csrf_token }}">
|
||||||
<button type="submit" class="button-secondary">Log out</button>
|
<button type="submit" class="button-secondary">Log out</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
|
||||||
|
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
|
<div>
|
||||||
<h2>
|
<h2 style="margin:0;">Users</h2>
|
||||||
Users
|
<div class="muted" style="margin-top:6px;">Configure Linux accounts controlled by this service.</div>
|
||||||
</h2>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{% if users %}
|
{% if users %}
|
||||||
|
|
||||||
|
|
||||||
{% for user in users %}
|
{% for user in users %}
|
||||||
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
||||||
|
|
||||||
<div class="user-header">
|
<div class="user-header">
|
||||||
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
<div class="username">{{ user.username }}</div>
|
||||||
<div class="username">
|
<div class="muted">Application user ID: {{ user.id }}</div>
|
||||||
|
|
||||||
{{ user.username }}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div>
|
|
||||||
|
|
||||||
Linux user ID:
|
|
||||||
{{ user.id }}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{% if user.enabled %}
|
{% if user.enabled %}
|
||||||
|
<span class="status status-enabled">Enabled</span>
|
||||||
<span class="status status-enabled">
|
|
||||||
|
|
||||||
Enabled
|
|
||||||
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
|
|
||||||
<span class="status status-disabled">
|
|
||||||
|
|
||||||
Disabled
|
|
||||||
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<a class="button button-primary" href="/admin/users/{{ user.id }}">Manage</a>
|
||||||
|
<form method="post" action="/admin/users/{{ user.id }}/delete">
|
||||||
<a
|
|
||||||
href="/admin/users/{{ user.id }}"
|
|
||||||
>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="button-primary"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
|
|
||||||
Manage
|
|
||||||
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</a>
|
|
||||||
|
|
||||||
|
|
||||||
<form
|
|
||||||
method="post"
|
|
||||||
action="/admin/users/{{ user.id }}/delete"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="csrf" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf" value="{{ csrf_token }}">
|
||||||
|
<button class="button-danger" type="submit" onclick="return confirm('Remove this user from parental control?');">Delete</button>
|
||||||
<button
|
|
||||||
class="button-danger"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
|
|
||||||
Delete
|
|
||||||
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
<div class="card empty">No users configured yet.</div>
|
||||||
|
|
||||||
<div class="card empty">
|
|
||||||
|
|
||||||
No users configured yet.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="card add-user">
|
<div class="card add-user">
|
||||||
|
<h2 style="margin-top:0;">Add User</h2>
|
||||||
|
<p class="muted">Select an existing regular Linux account. Root, system accounts, and accounts with standard sudo/wheel access are excluded.</p>
|
||||||
|
|
||||||
|
{% if available_users %}
|
||||||
<h2>
|
<form method="post" action="/admin/users">
|
||||||
Add User
|
<input type="hidden" name="csrf" value="{{ csrf_token }}">
|
||||||
</h2>
|
<select name="username" required>
|
||||||
|
<option value="" selected disabled>Select a Linux user</option>
|
||||||
|
{% for candidate in available_users %}
|
||||||
<p>
|
<option value="{{ candidate.username }}">
|
||||||
|
{{ candidate.username }}
|
||||||
Add an existing Linux account to
|
</option>
|
||||||
parental control.
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</p>
|
<button class="button-primary" type="submit">Add User</button>
|
||||||
|
|
||||||
|
|
||||||
<form
|
|
||||||
method="post"
|
|
||||||
action="/admin/users"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="csrf" value="{{ csrf_token }}">
|
|
||||||
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
placeholder="Linux username"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="button-primary"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
|
|
||||||
Add User
|
|
||||||
|
|
||||||
</button>
|
|
||||||
|
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="info">No eligible non-root Linux users are currently available to add.</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<strong>PAM administration group:</strong> <code>{{ pam_group }}</code>
|
||||||
|
<div class="muted" style="margin-top:6px;">Only Linux users who authenticate with PAM and belong to this group can use the web panel.</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+17
-64
@@ -3,80 +3,33 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Sign in — Parental Control</title>
|
<title>Parental Control Login</title>
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body {
|
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f4f5f7; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #1f2937; }
|
||||||
margin: 0;
|
.card { width: min(420px, calc(100% - 32px)); background: white; padding: 28px; border-radius: 14px; box-shadow: 0 4px 20px rgba(0,0,0,.08); }
|
||||||
min-height: 100vh;
|
h1 { margin-top: 0; }
|
||||||
display: grid;
|
label { display: block; margin: 14px 0 6px; font-size: 14px; font-weight: 600; }
|
||||||
place-items: center;
|
input { width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 15px; }
|
||||||
background: #f4f5f7;
|
button { width: 100%; margin-top: 20px; padding: 10px 14px; border: 0; border-radius: 8px; background: #2563eb; color: white; font-size: 15px; cursor: pointer; }
|
||||||
color: #1f2937;
|
.error { padding: 10px 12px; border-radius: 8px; background: #fee2e2; color: #991b1b; margin-bottom: 14px; font-size: 14px; }
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
.muted { color: #6b7280; font-size: 14px; line-height: 1.5; }
|
||||||
}
|
code { background: #f3f4f6; padding: 2px 5px; border-radius: 4px; }
|
||||||
.card {
|
|
||||||
width: min(420px, calc(100% - 32px));
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 28px;
|
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,.10);
|
|
||||||
}
|
|
||||||
h1 { margin: 0 0 8px; }
|
|
||||||
p { color: #6b7280; }
|
|
||||||
label { display:block; margin-top:16px; font-weight:600; font-size:14px; }
|
|
||||||
input {
|
|
||||||
width:100%;
|
|
||||||
margin-top:7px;
|
|
||||||
border:1px solid #d1d5db;
|
|
||||||
border-radius:7px;
|
|
||||||
padding:10px 12px;
|
|
||||||
font-size:15px;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
width:100%;
|
|
||||||
margin-top:22px;
|
|
||||||
border:0;
|
|
||||||
border-radius:7px;
|
|
||||||
padding:11px 15px;
|
|
||||||
background:#2563eb;
|
|
||||||
color:white;
|
|
||||||
cursor:pointer;
|
|
||||||
font-size:15px;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
background:#fef2f2;
|
|
||||||
color:#991b1b;
|
|
||||||
border:1px solid #fecaca;
|
|
||||||
border-radius:7px;
|
|
||||||
padding:10px 12px;
|
|
||||||
margin-top:16px;
|
|
||||||
}
|
|
||||||
.hint { font-size:13px; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="card">
|
<div class="card">
|
||||||
<h1>Parental Control</h1>
|
<h1>Parental Control</h1>
|
||||||
<p>Sign in with a Linux account authenticated through PAM.</p>
|
<p class="muted">Sign in with your Linux username and password. You must also be a member of the <code>{{ pam_group }}</code> Linux group.</p>
|
||||||
|
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||||
{% if error %}
|
|
||||||
<div class="error">{{ error }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="/login">
|
<form method="post" action="/login">
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
|
||||||
<label for="username">Linux username</label>
|
<label for="username">Linux username</label>
|
||||||
<input id="username" name="username" type="text" autocomplete="username" required autofocus>
|
<input id="username" name="username" autocomplete="username" required>
|
||||||
|
<label for="password">Linux password</label>
|
||||||
<label for="password">Password</label>
|
<input id="password" type="password" name="password" autocomplete="current-password" required>
|
||||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
||||||
|
|
||||||
<button type="submit">Sign in</button>
|
<button type="submit">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
<p class="hint">The application does not store your Linux password.</p>
|
|
||||||
</main>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+243
-775
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user