added graps
This commit is contained in:
+9
-7
@@ -3,9 +3,6 @@ import subprocess
|
||||
|
||||
from .storage import (
|
||||
get_user_policy,
|
||||
get_remaining_grant_seconds,
|
||||
consume_grant_seconds,
|
||||
record_usage,
|
||||
record_event,
|
||||
list_users,
|
||||
)
|
||||
@@ -40,6 +37,7 @@ def current_time():
|
||||
|
||||
|
||||
def is_inside_window(windows, minute: int) -> bool:
|
||||
# No configured windows means that no time-of-day restriction exists.
|
||||
if not windows:
|
||||
return True
|
||||
|
||||
@@ -57,14 +55,17 @@ def evaluate_user(user_id: int, username: str):
|
||||
usage_seconds,
|
||||
windows,
|
||||
grant_seconds,
|
||||
) = get_user_policy(user_id, weekday)
|
||||
) = get_user_policy(user_id, weekday, now.date())
|
||||
|
||||
inside_window = is_inside_window(windows, minute)
|
||||
allowance_remaining = max(0, allowance_seconds - usage_seconds)
|
||||
total_remaining = allowance_remaining + grant_seconds
|
||||
logged_in = user_has_session(username)
|
||||
|
||||
allowed_by_schedule = inside_window and allowance_remaining > 0
|
||||
# A user's normal allowance is always constrained by the configured
|
||||
# access window (when one exists). A temporary grant bypasses the window
|
||||
# and normal allowance by design.
|
||||
normal_access_available = allowance_remaining > 0
|
||||
allowed_by_schedule = inside_window and normal_access_available
|
||||
allowed_by_grant = grant_seconds > 0
|
||||
should_allow = allowed_by_schedule or allowed_by_grant
|
||||
|
||||
@@ -104,12 +105,13 @@ def evaluate_user(user_id: int, username: str):
|
||||
"weekday": weekday,
|
||||
"minute": minute,
|
||||
"inside_window": inside_window,
|
||||
"has_configured_windows": bool(windows),
|
||||
"logged_in": logged_in,
|
||||
"allowance_seconds": allowance_seconds,
|
||||
"usage_seconds": usage_seconds,
|
||||
"allowance_remaining": allowance_remaining,
|
||||
"grant_seconds": grant_seconds,
|
||||
"total_remaining": total_remaining,
|
||||
"normal_access_available": normal_access_available,
|
||||
"allowed": should_allow,
|
||||
}
|
||||
|
||||
|
||||
+52
-10
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
@@ -23,9 +24,12 @@ from .storage import (
|
||||
add_grant,
|
||||
list_grants,
|
||||
is_admin_allowed,
|
||||
get_usage_history,
|
||||
)
|
||||
from .users import (
|
||||
linux_user_exists,
|
||||
is_non_root_user,
|
||||
list_available_users,
|
||||
lock_user,
|
||||
unlock_user,
|
||||
terminate_user,
|
||||
@@ -34,13 +38,13 @@ from .users import (
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
SESSION_SECRET = __import__("os").environ.get(
|
||||
SESSION_SECRET = os.environ.get(
|
||||
"PARENTAL_CONTROL_SESSION_SECRET"
|
||||
) or secrets.token_urlsafe(32)
|
||||
|
||||
app = FastAPI(
|
||||
title="Parental Control",
|
||||
version="0.2.0",
|
||||
version="0.3.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
@@ -49,9 +53,7 @@ app.add_middleware(
|
||||
session_cookie="parental_control_session",
|
||||
max_age=8 * 60 * 60,
|
||||
same_site="lax",
|
||||
https_only=__import__("os").environ.get(
|
||||
"PARENTAL_CONTROL_HTTPS_ONLY", "0"
|
||||
) == "1",
|
||||
https_only=os.environ.get("PARENTAL_CONTROL_HTTPS_ONLY", "0") == "1",
|
||||
)
|
||||
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
@@ -83,6 +85,8 @@ def current_user(request: Request):
|
||||
if not username:
|
||||
return None
|
||||
|
||||
# Re-check the Linux PAM group on every request so removing an account
|
||||
# from the admin group takes effect without waiting for the session TTL.
|
||||
if not is_admin_allowed(username):
|
||||
request.session.clear()
|
||||
return None
|
||||
@@ -100,7 +104,6 @@ def require_web_auth(request: Request):
|
||||
f"/login?next={next_path}",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -144,16 +147,19 @@ class GrantRequest(BaseModel):
|
||||
def root():
|
||||
return {
|
||||
"application": "Parental Control",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"status": "running",
|
||||
"authentication": "PAM",
|
||||
"authentication": "PAM + pam Linux group",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/login")
|
||||
def login_page(request: Request, next: str = "/admin"):
|
||||
if current_user(request):
|
||||
return RedirectResponse(next if next.startswith("/") and not next.startswith("//") else "/admin", status_code=303)
|
||||
return RedirectResponse(
|
||||
next if next.startswith("/") and not next.startswith("//") else "/admin",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
@@ -161,6 +167,7 @@ def login_page(request: Request, next: str = "/admin"):
|
||||
context={
|
||||
"next": next if next.startswith("/") else "/admin",
|
||||
"error": None,
|
||||
"pam_group": get_config()["auth"].get("pam_group", "pam"),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -191,6 +198,7 @@ def login(
|
||||
context={
|
||||
"next": safe_next,
|
||||
"error": "Invalid Linux username or password.",
|
||||
"pam_group": get_config()["auth"].get("pam_group", "pam"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
@@ -201,7 +209,8 @@ def login(
|
||||
name="login.html",
|
||||
context={
|
||||
"next": safe_next,
|
||||
"error": "This Linux account is not allowed to access the administration panel.",
|
||||
"error": "Your Linux account is authenticated, but it is not a member of the PAM administration group.",
|
||||
"pam_group": get_config()["auth"].get("pam_group", "pam"),
|
||||
},
|
||||
status_code=403,
|
||||
)
|
||||
@@ -236,6 +245,14 @@ def list_users_api(request: Request):
|
||||
]
|
||||
|
||||
|
||||
@app.get("/api/users/{user_id}/usage")
|
||||
def user_usage_api(request: Request, user_id: int, days: int = 14):
|
||||
require_api_auth(request)
|
||||
if get_user(user_id) is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return get_usage_history(user_id, days)
|
||||
|
||||
|
||||
@app.post("/api/users/{user_id}/lock")
|
||||
def manually_lock(user_id: int, request: Request):
|
||||
require_api_auth(request)
|
||||
@@ -318,13 +335,21 @@ def admin_page(request: Request):
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
configured = {user["username"] for user in list_users()}
|
||||
available_users = [
|
||||
user for user in list_available_users()
|
||||
if user["username"] not in configured
|
||||
]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"users": list_users(),
|
||||
"available_users": available_users,
|
||||
"username": current_user(request),
|
||||
"csrf_token": csrf_token(request),
|
||||
"pam_group": get_config()["auth"].get("pam_group", "pam"),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -349,6 +374,12 @@ def admin_user_page(request: Request, user_id: int):
|
||||
key=lambda item: (int(item["weekday"]), int(item["start_minute"])),
|
||||
)
|
||||
|
||||
usage_history = get_usage_history(user_id, 14)
|
||||
total_used = sum(item["used_seconds"] for item in usage_history)
|
||||
total_allowance = sum(item["allowance_seconds"] for item in usage_history)
|
||||
today_used = usage_history[-1]["used_seconds"] if usage_history else 0
|
||||
today_allowance = usage_history[-1]["allowance_seconds"] if usage_history else 0
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="user.html",
|
||||
@@ -359,6 +390,11 @@ def admin_user_page(request: Request, user_id: int):
|
||||
"allowances": allowances,
|
||||
"windows": windows,
|
||||
"grants": list_grants(user_id),
|
||||
"usage_history": usage_history,
|
||||
"total_used": total_used,
|
||||
"total_allowance": total_allowance,
|
||||
"today_used": today_used,
|
||||
"today_allowance": today_allowance,
|
||||
"csrf_token": csrf_token(request),
|
||||
"username": current_user(request),
|
||||
},
|
||||
@@ -531,6 +567,12 @@ def admin_add_user(
|
||||
if not linux_user_exists(username):
|
||||
raise HTTPException(status_code=400, detail="Linux user does not exist")
|
||||
|
||||
if not is_non_root_user(username):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Only regular non-root Linux users can be added to parental control.",
|
||||
)
|
||||
|
||||
if get_user_by_username(username) is not None:
|
||||
raise HTTPException(status_code=400, detail="User is already configured")
|
||||
|
||||
|
||||
+35
-108
@@ -1,156 +1,86 @@
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from datetime import date
|
||||
from .enforcement import enforce_all_users
|
||||
from .storage import record_usage, get_user_policy, consume_grant_seconds
|
||||
from .storage import get_user_policy, record_usage, consume_grant_seconds
|
||||
|
||||
|
||||
CHECK_INTERVAL = 5
|
||||
|
||||
|
||||
class Scheduler:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
interval: int = CHECK_INTERVAL,
|
||||
):
|
||||
def __init__(self, interval: int = CHECK_INTERVAL):
|
||||
self.interval = interval
|
||||
|
||||
self._thread = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
self._last_usage_update = {}
|
||||
|
||||
def start(self):
|
||||
|
||||
if (
|
||||
self._thread is not None
|
||||
and self._thread.is_alive()
|
||||
):
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="parental-control-scheduler",
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
|
||||
self._stop_event.set()
|
||||
|
||||
if self._thread is not None:
|
||||
self._thread.join(
|
||||
timeout=self.interval + 2
|
||||
)
|
||||
self._thread.join(timeout=self.interval + 2)
|
||||
|
||||
def _run(self):
|
||||
|
||||
# Evaluate immediately when the
|
||||
# application starts.
|
||||
self._tick()
|
||||
|
||||
while not self._stop_event.wait(
|
||||
self.interval
|
||||
):
|
||||
while not self._stop_event.wait(self.interval):
|
||||
self._tick()
|
||||
|
||||
def _tick(self):
|
||||
|
||||
now = time.monotonic()
|
||||
|
||||
results = enforce_all_users()
|
||||
|
||||
for result in results:
|
||||
|
||||
user_id = result["user_id"]
|
||||
previous = self._last_usage_update.get(user_id)
|
||||
|
||||
if not result["logged_in"]:
|
||||
self._last_usage_update.pop(
|
||||
user_id,
|
||||
None,
|
||||
)
|
||||
continue
|
||||
if previous is not None:
|
||||
elapsed = max(0, int(now - previous["monotonic"]))
|
||||
if elapsed > 0 and previous["allowed"] and previous["logged_in"]:
|
||||
self._record_allowed_usage(
|
||||
user_id,
|
||||
elapsed,
|
||||
previous["weekday"],
|
||||
previous["date"],
|
||||
)
|
||||
|
||||
if not result["allowed"]:
|
||||
self._last_usage_update.pop(
|
||||
user_id,
|
||||
None,
|
||||
)
|
||||
continue
|
||||
|
||||
previous = (
|
||||
self._last_usage_update.get(
|
||||
user_id
|
||||
)
|
||||
)
|
||||
|
||||
self._last_usage_update[user_id] = now
|
||||
|
||||
if previous is None:
|
||||
continue
|
||||
|
||||
elapsed = int(
|
||||
now - previous
|
||||
)
|
||||
|
||||
if elapsed <= 0:
|
||||
continue
|
||||
|
||||
self._record_allowed_usage(
|
||||
user_id,
|
||||
elapsed,
|
||||
)
|
||||
|
||||
def _record_allowed_usage(
|
||||
self,
|
||||
user_id: int,
|
||||
seconds: int,
|
||||
):
|
||||
if result["logged_in"] and result["allowed"]:
|
||||
self._last_usage_update[user_id] = {
|
||||
"monotonic": now,
|
||||
"allowed": True,
|
||||
"logged_in": True,
|
||||
"weekday": result["weekday"],
|
||||
"date": result["timestamp"][:10],
|
||||
}
|
||||
else:
|
||||
self._last_usage_update.pop(user_id, None)
|
||||
|
||||
def _record_allowed_usage(self, user_id: int, seconds: int, weekday: int, usage_date: str):
|
||||
if seconds <= 0:
|
||||
return
|
||||
|
||||
weekday = datetime.now().weekday()
|
||||
|
||||
(
|
||||
allowance_seconds,
|
||||
usage_seconds,
|
||||
windows,
|
||||
_windows,
|
||||
grant_seconds,
|
||||
) = get_user_policy(
|
||||
user_id,
|
||||
weekday,
|
||||
)
|
||||
) = get_user_policy(user_id, weekday)
|
||||
|
||||
allowance_remaining = max(
|
||||
0,
|
||||
allowance_seconds
|
||||
- usage_seconds,
|
||||
)
|
||||
|
||||
normal_usage = min(
|
||||
seconds,
|
||||
allowance_remaining,
|
||||
)
|
||||
|
||||
grant_usage = (
|
||||
seconds
|
||||
- normal_usage
|
||||
)
|
||||
|
||||
if grant_usage > grant_seconds:
|
||||
grant_usage = grant_seconds
|
||||
|
||||
total_usage = (
|
||||
normal_usage
|
||||
+ grant_usage
|
||||
)
|
||||
allowance_remaining = max(0, allowance_seconds - usage_seconds)
|
||||
normal_usage = min(seconds, allowance_remaining)
|
||||
grant_usage = min(max(0, seconds - normal_usage), grant_seconds)
|
||||
total_usage = normal_usage + grant_usage
|
||||
|
||||
if total_usage <= 0:
|
||||
return
|
||||
@@ -158,14 +88,11 @@ class Scheduler:
|
||||
record_usage(
|
||||
user_id,
|
||||
total_usage,
|
||||
date.fromisoformat(usage_date),
|
||||
)
|
||||
|
||||
if grant_usage > 0:
|
||||
|
||||
consume_grant_seconds(
|
||||
user_id,
|
||||
grant_usage,
|
||||
)
|
||||
consume_grant_seconds(user_id, grant_usage)
|
||||
|
||||
|
||||
scheduler = Scheduler()
|
||||
|
||||
+75
-40
@@ -1,10 +1,9 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from datetime import datetime, date, timedelta
|
||||
from threading import RLock
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -19,7 +18,7 @@ DEFAULT_CONFIG = {
|
||||
"version": 1,
|
||||
"auth": {
|
||||
"pam_service": "login",
|
||||
"admin_users": [],
|
||||
"pam_group": "pam",
|
||||
},
|
||||
"users": [],
|
||||
}
|
||||
@@ -68,9 +67,13 @@ def _load_yaml():
|
||||
|
||||
data.setdefault("version", 1)
|
||||
data.setdefault("auth", {})
|
||||
if not isinstance(data["auth"], dict):
|
||||
raise ValueError("config.yaml auth must be an object")
|
||||
data["auth"].setdefault("pam_service", "login")
|
||||
data["auth"].setdefault("admin_users", [])
|
||||
data["auth"].setdefault("pam_group", "pam")
|
||||
data.setdefault("users", [])
|
||||
if not isinstance(data["users"], list):
|
||||
raise ValueError("config.yaml users must be a list")
|
||||
return data
|
||||
|
||||
|
||||
@@ -124,8 +127,6 @@ def initialize_storage():
|
||||
if not STATE_PATH.exists():
|
||||
_save_json(DEFAULT_STATE)
|
||||
|
||||
# Keep files usable after manual edits while avoiding destructive
|
||||
# initialization or recreation of any database.
|
||||
config = _load_yaml()
|
||||
state = _load_json()
|
||||
_save_yaml(config)
|
||||
@@ -141,14 +142,14 @@ def get_pam_service():
|
||||
return get_config()["auth"].get("pam_service", "login")
|
||||
|
||||
|
||||
def admin_users():
|
||||
value = get_config()["auth"].get("admin_users", [])
|
||||
return {str(item) for item in value}
|
||||
def get_pam_group():
|
||||
return get_config()["auth"].get("pam_group", "pam")
|
||||
|
||||
|
||||
def is_admin_allowed(username: str) -> bool:
|
||||
allowed = admin_users()
|
||||
return not allowed or username in allowed
|
||||
"""Keep authorization in users.py so PAM group membership is system-backed."""
|
||||
from .users import user_in_group
|
||||
return user_in_group(username, get_pam_group())
|
||||
|
||||
|
||||
def _find_user(config, user_id):
|
||||
@@ -256,7 +257,7 @@ def set_allowance(user_id: int, weekday: int, seconds: int):
|
||||
raise KeyError("User not found")
|
||||
|
||||
user.setdefault("allowances", {})
|
||||
user["allowances"][str(weekday)] = int(seconds)
|
||||
user["allowances"][str(weekday)] = max(0, int(seconds))
|
||||
_save_yaml(config)
|
||||
|
||||
|
||||
@@ -298,7 +299,26 @@ def delete_window(window_id: int):
|
||||
return int(owner["id"])
|
||||
|
||||
|
||||
def get_user_policy(user_id: int, weekday: int):
|
||||
def _active_grant_seconds(state, user_id: int, now_iso: str) -> int:
|
||||
return sum(
|
||||
int(grant["remaining_seconds"])
|
||||
for grant in state["temporary_grants"]
|
||||
if int(grant["user_id"]) == int(user_id)
|
||||
and not grant.get("consumed", False)
|
||||
and int(grant.get("remaining_seconds", 0)) > 0
|
||||
and (
|
||||
grant.get("expires_at") is None
|
||||
or grant["expires_at"] > now_iso
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_user_policy(user_id: int, weekday: int, on_date: date | None = None):
|
||||
"""Return the complete policy for a specific weekday/date.
|
||||
|
||||
Configuration is always read from the current files, so allowance and
|
||||
access-window changes are order-independent.
|
||||
"""
|
||||
with _lock:
|
||||
config = _load_yaml()
|
||||
user = _find_user(config, user_id)
|
||||
@@ -318,22 +338,14 @@ def get_user_policy(user_id: int, weekday: int):
|
||||
)
|
||||
|
||||
state = _load_json()
|
||||
today = datetime.now().date().isoformat()
|
||||
target_date = on_date or datetime.now().date()
|
||||
today = target_date.isoformat()
|
||||
usage_seconds = int(
|
||||
state["usage"].get(f"{int(user_id)}:{today}", 0)
|
||||
)
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
grant_seconds = sum(
|
||||
int(grant["remaining_seconds"])
|
||||
for grant in state["temporary_grants"]
|
||||
if int(grant["user_id"]) == int(user_id)
|
||||
and not grant.get("consumed", False)
|
||||
and (
|
||||
grant.get("expires_at") is None
|
||||
or grant["expires_at"] > now
|
||||
)
|
||||
)
|
||||
grant_seconds = _active_grant_seconds(state, user_id, now)
|
||||
|
||||
return allowance_seconds, usage_seconds, windows, grant_seconds
|
||||
|
||||
@@ -341,17 +353,10 @@ def get_user_policy(user_id: int, weekday: int):
|
||||
def get_remaining_grant_seconds(user_id: int) -> int:
|
||||
with _lock:
|
||||
state = _load_json()
|
||||
now = datetime.now().isoformat()
|
||||
return sum(
|
||||
int(grant["remaining_seconds"])
|
||||
for grant in state["temporary_grants"]
|
||||
if int(grant["user_id"]) == int(user_id)
|
||||
and not grant.get("consumed", False)
|
||||
and int(grant["remaining_seconds"]) > 0
|
||||
and (
|
||||
grant.get("expires_at") is None
|
||||
or grant["expires_at"] > now
|
||||
)
|
||||
return _active_grant_seconds(
|
||||
state,
|
||||
user_id,
|
||||
datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@@ -407,18 +412,49 @@ def consume_grant_seconds(user_id: int, seconds: int):
|
||||
_save_json(state)
|
||||
|
||||
|
||||
def record_usage(user_id: int, seconds: int):
|
||||
def record_usage(user_id: int, seconds: int, usage_date: date | None = None):
|
||||
if seconds <= 0:
|
||||
return
|
||||
|
||||
with _lock:
|
||||
state = _load_json()
|
||||
today = datetime.now().date().isoformat()
|
||||
key = f"{int(user_id)}:{today}"
|
||||
target_date = usage_date or datetime.now().date()
|
||||
key = f"{int(user_id)}:{target_date.isoformat()}"
|
||||
state["usage"][key] = int(state["usage"].get(key, 0)) + int(seconds)
|
||||
_save_json(state)
|
||||
|
||||
|
||||
def get_usage_history(user_id: int, days: int = 14):
|
||||
days = max(1, min(int(days), 90))
|
||||
|
||||
with _lock:
|
||||
config = _load_yaml()
|
||||
state = _load_json()
|
||||
user = _find_user(config, user_id)
|
||||
if user is None:
|
||||
return []
|
||||
|
||||
today = datetime.now().date()
|
||||
history = []
|
||||
|
||||
for offset in range(days - 1, -1, -1):
|
||||
day = today - timedelta(days=offset)
|
||||
weekday = day.weekday()
|
||||
allowance = int(
|
||||
user.get("allowances", {}).get(str(weekday), 0)
|
||||
)
|
||||
key = f"{int(user_id)}:{day.isoformat()}"
|
||||
used = int(state["usage"].get(key, 0))
|
||||
history.append({
|
||||
"date": day.isoformat(),
|
||||
"weekday": weekday,
|
||||
"used_seconds": used,
|
||||
"allowance_seconds": allowance,
|
||||
})
|
||||
|
||||
return history
|
||||
|
||||
|
||||
def list_grants(user_id: int, limit: int = 20):
|
||||
with _lock:
|
||||
state = _load_json()
|
||||
@@ -440,6 +476,5 @@ def record_event(user_id, event_type: str, details: str = ""):
|
||||
"details": details,
|
||||
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||
})
|
||||
# Keep the state file bounded.
|
||||
state["events"] = state["events"][-2000:]
|
||||
_save_json(state)
|
||||
|
||||
+114
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user