From 47c292f4cd3a099daeb645100eee3a5830114bff Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 17 Sep 2026 20:41:35 +0500 Subject: [PATCH] updated gui and enforcement --- app/config.py | 569 ++++++++++++++++ app/database.py | 105 --- app/enforcement.py | 1011 +++++++++++++++++++++-------- app/ipc.py | 977 +++++++++++++++++++--------- app/main.py | 21 +- app/models.py | 34 - app/scheduler.py | 170 ++--- app/users.py | 154 ++++- config/state.json | 4 + config/users.yaml | 28 + data/.gitkeep | 0 desktop/client.py | 980 +++++++++++++++++++++++----- install.sh | 78 ++- org.parentalcontrol.modify.policy | 26 + requirements.txt | 3 +- 15 files changed, 3065 insertions(+), 1095 deletions(-) create mode 100644 app/config.py delete mode 100644 app/database.py delete mode 100644 app/models.py create mode 100644 config/state.json create mode 100644 config/users.yaml delete mode 100644 data/.gitkeep create mode 100644 org.parentalcontrol.modify.policy diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..cb66aa2 --- /dev/null +++ b/app/config.py @@ -0,0 +1,569 @@ +from __future__ import annotations + +import copy +import json +import os +import tempfile +import threading +from pathlib import Path +from typing import Any + +import yaml + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +CONFIG_DIR = PROJECT_ROOT / "config" + +USERS_FILE = CONFIG_DIR / "users.yaml" +STATE_FILE = CONFIG_DIR / "state.json" + +DAY_NAMES = ( + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", +) + +_config_lock = threading.RLock() + + +DEFAULT_USERS_CONFIG = { + "users": [] +} + +DEFAULT_STATE = { + "usage": {}, + "temporary_grants": [], +} + + +def _ensure_config_dir() -> None: + CONFIG_DIR.mkdir( + parents=True, + exist_ok=True, + ) + + +def _atomic_write( + path: Path, + content: str, +) -> None: + _ensure_config_dir() + + directory = path.parent + + fd, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", + suffix=".tmp", + dir=directory, + text=True, + ) + + temporary_path = Path(temporary_name) + + try: + with os.fdopen( + fd, + "w", + encoding="utf-8", + ) as file: + file.write(content) + file.flush() + os.fsync(file.fileno()) + + os.replace( + temporary_path, + path, + ) + + finally: + try: + temporary_path.unlink() + except FileNotFoundError: + pass + + +def _normalize_users_config( + data: Any, +) -> dict[str, Any]: + if not isinstance(data, dict): + return copy.deepcopy( + DEFAULT_USERS_CONFIG + ) + + users = data.get("users") + + if not isinstance(users, list): + users = [] + + normalized_users = [] + + for user in users: + if not isinstance(user, dict): + continue + + normalized = dict(user) + + normalized.setdefault( + "enabled", + True, + ) + + normalized.setdefault( + "daily_allowance", + {}, + ) + + normalized.setdefault( + "access_windows", + {}, + ) + + normalized_users.append( + normalized + ) + + return { + "users": normalized_users + } + + +def _normalize_state( + data: Any, +) -> dict[str, Any]: + if not isinstance(data, dict): + return copy.deepcopy( + DEFAULT_STATE + ) + + state = dict(data) + + if not isinstance( + state.get("usage"), + dict, + ): + state["usage"] = {} + + if not isinstance( + state.get("temporary_grants"), + list, + ): + state["temporary_grants"] = [] + + return state + + +def load_users_config() -> dict[str, Any]: + with _config_lock: + _ensure_config_dir() + + if not USERS_FILE.exists(): + save_users_config( + DEFAULT_USERS_CONFIG + ) + + try: + with USERS_FILE.open( + "r", + encoding="utf-8", + ) as file: + data = yaml.safe_load(file) + + except ( + OSError, + yaml.YAMLError, + ): + raise RuntimeError( + f"Unable to read {USERS_FILE}" + ) + + return _normalize_users_config( + data + ) + + +def save_users_config( + data: dict[str, Any], +) -> None: + with _config_lock: + normalized = _normalize_users_config( + data + ) + + content = yaml.safe_dump( + normalized, + sort_keys=False, + allow_unicode=True, + default_flow_style=False, + ) + + _atomic_write( + USERS_FILE, + content, + ) + + +def load_state() -> dict[str, Any]: + with _config_lock: + _ensure_config_dir() + + if not STATE_FILE.exists(): + save_state( + DEFAULT_STATE + ) + + try: + with STATE_FILE.open( + "r", + encoding="utf-8", + ) as file: + data = json.load(file) + + except ( + OSError, + json.JSONDecodeError, + ): + raise RuntimeError( + f"Unable to read {STATE_FILE}" + ) + + return _normalize_state( + data + ) + + +def save_state( + data: dict[str, Any], +) -> None: + with _config_lock: + normalized = _normalize_state( + data + ) + + content = json.dumps( + normalized, + indent=2, + ensure_ascii=False, + ) + + _atomic_write( + STATE_FILE, + content, + ) + + +def next_user_id( + users: list[dict[str, Any]], +) -> int: + highest = 0 + + for user in users: + try: + user_id = int( + user.get("id", 0) + ) + except ( + TypeError, + ValueError, + ): + continue + + highest = max( + highest, + user_id, + ) + + return highest + 1 + + +def find_user( + user_id: int, +) -> dict[str, Any] | None: + config = load_users_config() + + try: + requested_id = int(user_id) + except ( + TypeError, + ValueError, + ): + return None + + for user in config["users"]: + try: + current_id = int( + user.get("id") + ) + except ( + TypeError, + ValueError, + ): + continue + + if current_id == requested_id: + return user + + return None + + +def find_user_by_username( + username: str, +) -> dict[str, Any] | None: + config = load_users_config() + + for user in config["users"]: + if ( + str( + user.get("username", "") + ) + == username + ): + return user + + return None + + +def get_day_name( + weekday: int, +) -> str: + weekday = int(weekday) + + if not 0 <= weekday <= 6: + raise ValueError( + "weekday must be between 0 and 6" + ) + + return DAY_NAMES[weekday] + + +def normalize_day_name( + day: str, +) -> str: + value = str(day).strip().lower() + + aliases = { + "sun": "sunday", + "mon": "monday", + "tue": "tuesday", + "tues": "tuesday", + "wed": "wednesday", + "thu": "thursday", + "thur": "thursday", + "thurs": "thursday", + "fri": "friday", + "sat": "saturday", + } + + return aliases.get( + value, + value, + ) + + +def python_weekday_to_name( + python_weekday: int, +) -> str: + python_weekday = int( + python_weekday + ) + + if not 0 <= python_weekday <= 6: + raise ValueError( + "weekday must be between 0 and 6" + ) + + return DAY_NAMES[ + (python_weekday + 1) % 7 + ] + + +def parse_time( + value: Any, +) -> int: + if isinstance(value, int): + return max( + 0, + min( + value, + 1439, + ), + ) + + text = str(value).strip() + + if ":" not in text: + return max( + 0, + min( + int(text), + 1439, + ), + ) + + hours_text, minutes_text = ( + text.split( + ":", + 1, + ) + ) + + hours = int( + hours_text + ) + + minutes = int( + minutes_text + ) + + if not 0 <= hours <= 23: + raise ValueError( + f"Invalid hour: {hours}" + ) + + if not 0 <= minutes <= 59: + raise ValueError( + f"Invalid minute: {minutes}" + ) + + return ( + hours * 60 + + minutes + ) + + +def format_time( + minute: int, +) -> str: + minute = int(minute) + + minute = max( + 0, + min( + minute, + 1439, + ), + ) + + hours = minute // 60 + minutes = minute % 60 + + return ( + f"{hours:02d}:" + f"{minutes:02d}" + ) + + +def normalize_allowances( + allowances: Any, +) -> dict[str, int]: + if not isinstance( + allowances, + dict, + ): + allowances = {} + + result = {} + + for day in DAY_NAMES: + value = allowances.get( + day, + 0, + ) + + try: + seconds = int(value) + except ( + TypeError, + ValueError, + ): + seconds = 0 + + result[day] = max( + 0, + seconds, + ) + + return result + + +def normalize_access_windows( + windows: Any, +) -> dict[str, list[dict[str, str]]]: + if not isinstance( + windows, + dict, + ): + windows = {} + + result = {} + + for day in DAY_NAMES: + day_windows = windows.get( + day, + [], + ) + + if not isinstance( + day_windows, + list, + ): + continue + + normalized = [] + + for window in day_windows: + if isinstance( + window, + dict, + ): + start = window.get( + "start" + ) + end = window.get( + "end" + ) + + elif ( + isinstance( + window, + (list, tuple), + ) + and len(window) >= 2 + ): + start = window[0] + end = window[1] + + else: + continue + + try: + start_minute = parse_time( + start + ) + end_minute = parse_time( + end + ) + except ( + TypeError, + ValueError, + ): + continue + + normalized.append( + { + "start": format_time( + start_minute + ), + "end": format_time( + end_minute + ), + } + ) + + if normalized: + result[day] = normalized + + return result diff --git a/app/database.py b/app/database.py deleted file mode 100644 index 1ab22fa..0000000 --- a/app/database.py +++ /dev/null @@ -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() diff --git a/app/enforcement.py b/app/enforcement.py index f8e9112..c4d7f7c 100644 --- a/app/enforcement.py +++ b/app/enforcement.py @@ -1,16 +1,35 @@ +from __future__ import annotations + from datetime import datetime import subprocess +import threading -from .database import get_db +from .config import ( + DAY_NAMES, + find_user, + get_day_name, + load_state, + load_users_config, + normalize_access_windows, + normalize_allowances, + parse_time, + python_weekday_to_name, + save_state, +) from .users import ( - lock_user, - unlock_user, - terminate_user, is_locked, + lock_user, + terminate_user, + unlock_user, ) -def user_has_session(username: str) -> bool: +_state_lock = threading.RLock() + + +def user_has_session( + username: str, +) -> bool: result = subprocess.run( [ "loginctl", @@ -28,287 +47,581 @@ def user_has_session(username: str) -> bool: for line in result.stdout.splitlines(): parts = line.split() - if len(parts) >= 2 and parts[1] == username: - return True + if len(parts) >= 2: + if parts[1] == username: + return True return False def current_time(): now = datetime.now() - weekday = now.weekday() - minute = now.hour * 60 + now.minute - return now, weekday, minute + weekday = python_weekday_to_name( + now.weekday() + ) - -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"] + minute = ( + now.hour * 60 + + now.minute + ) return ( - allowance_seconds, - usage_seconds, - windows, - grant_seconds, + now, + weekday, + minute, ) -def is_inside_window(windows, minute: int) -> bool: - if not windows: +def _today() -> str: + return ( + datetime.now() + .date() + .isoformat() + ) + + +def _get_usage( + state: dict, + username: str, + date: str, +) -> int: + usage = state.setdefault( + "usage", + {}, + ) + + user_usage = usage.get( + username, + {}, + ) + + if not isinstance( + user_usage, + dict, + ): + return 0 + + try: + return max( + 0, + int( + user_usage.get( + date, + 0, + ) + ), + ) + except ( + TypeError, + ValueError, + ): + return 0 + + +def _set_usage( + state: dict, + username: str, + date: str, + seconds: int, +) -> None: + usage = state.setdefault( + "usage", + {}, + ) + + user_usage = usage.setdefault( + username, + {}, + ) + + user_usage[date] = max( + 0, + int(seconds), + ) + + +def _get_grants( + state: dict, +) -> list[dict]: + grants = state.setdefault( + "temporary_grants", + [], + ) + + if not isinstance( + grants, + list, + ): + grants = [] + state[ + "temporary_grants" + ] = grants + + return grants + + +def _grant_is_active( + grant: dict, + username: str, + now: datetime, +) -> bool: + if str( + grant.get("username", "") + ) != username: + return False + + try: + remaining = int( + grant.get( + "remaining_seconds", + 0, + ) + ) + except ( + TypeError, + ValueError, + ): + return False + + if remaining <= 0: + return False + + expires_at = grant.get( + "expires_at" + ) + + if not expires_at: return True - for window in windows: - if ( - window["start_minute"] - <= minute - < window["end_minute"] + try: + expiry = datetime.fromisoformat( + str(expires_at) + ) + except ValueError: + return False + + return expiry > now + + +def get_remaining_grant_seconds( + user_id: int, +) -> int: + user = find_user( + user_id + ) + + if user is None: + return 0 + + username = str( + user.get( + "username", + "", + ) + ) + + now = datetime.now() + + with _state_lock: + state = load_state() + + total = 0 + + for grant in _get_grants( + state ): - return True - - return False - - -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, + if _grant_is_active( + grant, + username, now, - ), - ).fetchone() + ): + try: + total += int( + grant.get( + "remaining_seconds", + 0, + ) + ) + except ( + TypeError, + ValueError, + ): + pass - return row["total"] + return max( + 0, + total, + ) def consume_grant_seconds( user_id: int, seconds: int, -): +) -> None: if seconds <= 0: return - now = datetime.now().isoformat() + user = find_user( + user_id + ) - 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() + if user is None: + return - remaining = seconds + username = str( + user.get( + "username", + "", + ) + ) - for grant in grants: - if remaining <= 0: + now = datetime.now() + + with _state_lock: + state = load_state() + + remaining_to_consume = ( + int(seconds) + ) + + for grant in _get_grants( + state + ): + if remaining_to_consume <= 0: break - available = grant["remaining_seconds"] + if not _grant_is_active( + grant, + username, + now, + ): + continue + + try: + available = int( + grant.get( + "remaining_seconds", + 0, + ) + ) + except ( + TypeError, + ValueError, + ): + continue consumed = min( available, - remaining, + remaining_to_consume, ) - new_remaining = ( - available - consumed + grant[ + "remaining_seconds" + ] = available - consumed + + remaining_to_consume -= ( + consumed ) - db.execute( - """ - UPDATE temporary_grants - SET remaining_seconds = ?, - consumed = ? - WHERE id = ? - """, - ( - new_remaining, - 1 if new_remaining <= 0 else 0, - grant["id"], - ), - ) + if ( + grant[ + "remaining_seconds" + ] <= 0 + ): + grant[ + "remaining_seconds" + ] = 0 - remaining -= consumed + save_state( + state + ) def record_usage( user_id: int, seconds: int, -): +) -> None: if seconds <= 0: return - today = datetime.now().date().isoformat() + user = find_user( + user_id + ) - with get_db() as db: - row = db.execute( - """ - SELECT used_seconds - FROM usage - WHERE user_id = ? - AND date = ? - """, - ( - user_id, - today, - ), - ).fetchone() + if user is None: + return - 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, - ), - ) + username = str( + user.get( + "username", + "", + ) + ) + + today = _today() + + with _state_lock: + state = load_state() + + current = _get_usage( + state, + username, + today, + ) + + _set_usage( + state, + username, + today, + current + int(seconds), + ) + + save_state( + state + ) -def record_event( +def get_user_policy( user_id: int, - event_type: str, - details: str = "", + weekday: str, ): - with get_db() as db: - db.execute( - """ - INSERT INTO events ( - user_id, - event_type, - details + user = find_user( + user_id + ) + + if user is None: + return ( + 0, + 0, + [], + 0, + ) + + allowances = normalize_allowances( + user.get( + "daily_allowance", + {}, + ) + ) + + allowance_seconds = ( + allowances.get( + weekday, + 0, + ) + ) + + today = _today() + + with _state_lock: + state = load_state() + + username = str( + user.get( + "username", + "", ) - VALUES (?, ?, ?) - """, - ( - user_id, - event_type, - details, - ), + ) + + usage_seconds = _get_usage( + state, + username, + today, + ) + + grants = _get_grants( + state + ) + + now = datetime.now() + + grant_seconds = 0 + + for grant in grants: + if _grant_is_active( + grant, + username, + now, + ): + try: + grant_seconds += int( + grant.get( + "remaining_seconds", + 0, + ) + ) + except ( + TypeError, + ValueError, + ): + pass + + access_windows = normalize_access_windows( + user.get( + "access_windows", + {}, + ) + ) + + windows = access_windows.get( + weekday, + [], + ) + + return ( + allowance_seconds, + usage_seconds, + windows, + max( + 0, + grant_seconds, + ), + ) + + +def is_inside_window( + windows, + minute: int, +) -> bool: + if not windows: + return True + + for window in windows: + try: + start = parse_time( + window["start"] + ) + end = parse_time( + window["end"] + ) + except ( + KeyError, + TypeError, + ValueError, + ): + continue + + # Same time means all day. + if start == end: + return True + + # Normal same-day window. + if start < end: + if start <= minute < end: + return True + + # Overnight window. + else: + if ( + minute >= start + or minute < end + ): + return True + + return False + + +def _clean_old_state() -> None: + today = _today() + + with _state_lock: + state = load_state() + + usage = state.get( + "usage", + {}, + ) + + if isinstance( + usage, + dict, + ): + for username in list( + usage.keys() + ): + user_usage = usage[ + username + ] + + if not isinstance( + user_usage, + dict, + ): + del usage[ + username + ] + continue + + # Keep recent history instead + # of deleting everything. + dates = sorted( + user_usage.keys() + ) + + if len(dates) > 31: + for old_date in dates[ + :-31 + ]: + del user_usage[ + old_date + ] + + grants = state.get( + "temporary_grants", + [], + ) + + if isinstance( + grants, + list, + ): + now = datetime.now() + + cleaned = [] + + for grant in grants: + try: + remaining = int( + grant.get( + "remaining_seconds", + 0, + ) + ) + except ( + TypeError, + ValueError, + ): + continue + + if remaining <= 0: + continue + + expires_at = grant.get( + "expires_at" + ) + + if expires_at: + try: + expiry = datetime.fromisoformat( + str(expires_at) + ) + except ValueError: + continue + + if expiry <= now: + continue + + cleaned.append( + grant + ) + + state[ + "temporary_grants" + ] = cleaned + + save_state( + state ) @@ -316,7 +629,102 @@ def evaluate_user( user_id: int, username: str, ): - now, weekday, minute = current_time() + now, weekday, minute = ( + current_time() + ) + + user = find_user( + user_id + ) + + if user is None: + return { + "user_id": user_id, + "username": username, + "timestamp": now.isoformat(), + "weekday": weekday, + "minute": minute, + "inside_window": False, + "logged_in": False, + "allowance_seconds": 0, + "usage_seconds": 0, + "allowance_remaining": 0, + "grant_seconds": 0, + "total_remaining": 0, + "allowed_by_schedule": False, + "allowed_by_grant": False, + "allowed": False, + } + + if str( + user.get( + "username", + "", + ) + ) != username: + return { + "user_id": user_id, + "username": username, + "timestamp": now.isoformat(), + "weekday": weekday, + "minute": minute, + "inside_window": False, + "logged_in": False, + "allowance_seconds": 0, + "usage_seconds": 0, + "allowance_remaining": 0, + "grant_seconds": 0, + "total_remaining": 0, + "allowed_by_schedule": False, + "allowed_by_grant": False, + "allowed": False, + } + + if not bool( + user.get( + "enabled", + True, + ) + ): + logged_in = user_has_session( + username + ) + + if logged_in: + try: + terminate_user( + username + ) + except Exception: + pass + + try: + if not is_locked( + username + ): + lock_user( + username + ) + except Exception: + pass + + return { + "user_id": user_id, + "username": username, + "timestamp": now.isoformat(), + "weekday": weekday, + "minute": minute, + "inside_window": False, + "logged_in": logged_in, + "allowance_seconds": 0, + "usage_seconds": 0, + "allowance_remaining": 0, + "grant_seconds": 0, + "total_remaining": 0, + "allowed_by_schedule": False, + "allowed_by_grant": False, + "allowed": False, + } ( allowance_seconds, @@ -335,7 +743,8 @@ def evaluate_user( allowance_remaining = max( 0, - allowance_seconds - usage_seconds, + allowance_seconds + - usage_seconds, ) total_remaining = ( @@ -361,50 +770,53 @@ def evaluate_user( or allowed_by_grant ) - locked = is_locked(username) + try: + locked = is_locked( + username + ) + except Exception: + locked = False if should_allow: if locked: try: - unlock_user(username) + unlock_user( + username + ) + + # Re-check the state after + # unlocking. + if not is_locked( + username + ): + pass - record_event( - user_id, - "auto_unlock", - "Access became available", - ) except Exception as exc: - record_event( - user_id, - "unlock_error", - str(exc), - ) + with _state_lock: + state = load_state() + + # The enforcement loop must + # continue even if one account + # cannot be unlocked. + _ = state + _ = exc else: if logged_in: - terminate_user(username) - - record_event( - user_id, - "session_terminated", - "Access is not currently permitted", - ) + try: + terminate_user( + username + ) + except Exception: + pass 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), + lock_user( + username ) + except Exception: + pass return { "user_id": user_id, @@ -419,37 +831,72 @@ def evaluate_user( "allowance_remaining": allowance_remaining, "grant_seconds": grant_seconds, "total_remaining": total_remaining, + "allowed_by_schedule": allowed_by_schedule, + "allowed_by_grant": allowed_by_grant, "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() + _clean_old_state() + + config = load_users_config() + + users = config.get( + "users", + [], + ) results = [] for user in users: + if not isinstance( + user, + dict, + ): + continue + + if not bool( + user.get( + "enabled", + True, + ) + ): + continue + try: - result = evaluate_user( - user["id"], - user["username"], + user_id = int( + user["id"] ) - results.append(result) + username = str( + user["username"] + ) + + except ( + KeyError, + TypeError, + ValueError, + ): + continue + + try: + result = evaluate_user( + user_id, + username, + ) + + results.append( + result + ) except Exception as exc: - record_event( - user["id"], - "enforcement_error", - str(exc), + results.append( + { + "user_id": user_id, + "username": username, + "error": str(exc), + } ) return results diff --git a/app/ipc.py b/app/ipc.py index b2ae5a7..4751a5d 100644 --- a/app/ipc.py +++ b/app/ipc.py @@ -1,52 +1,63 @@ +from __future__ import annotations import json import os import socket -import sqlite3 import struct import subprocess import threading from pathlib import Path +from typing import Any -from .database import get_db +from .config import ( + DAY_NAMES, + find_user, + load_users_config, + next_user_id, + normalize_access_windows, + normalize_allowances, + save_users_config, +) +from .enforcement import ( + get_remaining_grant_seconds, +) +from .users import user_exists -SOCKET_PATH = Path("/run/parental-control.sock") +SOCKET_PATH = Path( + "/run/parental-control.sock" +) BUFFER_SIZE = 65536 -POLKIT_ACTION = "org.sarlink.parentalcontrol.modify" +POLKIT_ACTION = ( + "org.parentalcontrol.modify" +) -# ============================================================ -# Peer credentials and authorization -# ============================================================ - -def get_peer_credentials(connection): +def get_peer_credentials( + connection, +): credentials = connection.getsockopt( socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i"), ) - return struct.unpack("3i", credentials) + return struct.unpack( + "3i", + credentials, + ) -def authorize_write(pid: int, uid: int) -> bool: - """ - Authorize administrative operations. - - Root is automatically authorized. - - Non-root users must pass Polkit authorization. - """ - - # Root is already an administrator. - if uid == 0: - return True - +def authorize_write( + pid: int, +) -> bool: try: - os.kill(pid, 0) + os.kill( + pid, + 0, + ) except OSError: return False @@ -71,134 +82,348 @@ def authorize_write(pid: int, uid: int) -> bool: return False -# ============================================================ -# Database operations -# ============================================================ +def _user_to_api( + user: dict[str, Any], +) -> dict[str, Any]: + user_id = int( + user.get( + "id", + 0, + ) + ) + + username = str( + user.get( + "username", + "", + ) + ) + + enabled = bool( + user.get( + "enabled", + True, + ) + ) + + allowances = normalize_allowances( + user.get( + "daily_allowance", + {}, + ) + ) + + access_windows = normalize_access_windows( + user.get( + "access_windows", + {}, + ) + ) + + return { + "id": user_id, + "username": username, + "enabled": enabled, + "daily_allowance": allowances, + "allowances": allowances, + "access_windows": access_windows, + } + + +def _find_user_or_raise( + user_id: int, +) -> tuple[dict, dict, int]: + config = load_users_config() + + try: + requested_id = int( + user_id + ) + except ( + TypeError, + ValueError, + ): + raise ValueError( + f"Invalid user id: {user_id}" + ) + + for index, user in enumerate( + config["users"] + ): + try: + current_id = int( + user.get("id") + ) + except ( + TypeError, + ValueError, + ): + continue + + if current_id == requested_id: + return ( + config, + user, + index, + ) + + raise ValueError( + f"User not found: {user_id}" + ) + + +def _parse_access_windows( + access_windows, +) -> dict: + return normalize_access_windows( + access_windows + ) + + +def _parse_allowances( + allowances, +) -> dict: + """ + Normalize allowance data coming from the GUI. + + The original GUI uses numeric weekday indexes: + + 0 = sunday + 1 = monday + 2 = tuesday + 3 = wednesday + 4 = thursday + 5 = friday + 6 = saturday + + The configuration format uses weekday names. + + Accept both formats so the IPC layer remains compatible + with the original GUI and the config file format. + """ + + if not isinstance( + allowances, + dict, + ): + return normalize_allowances( + {} + ) + + converted = {} + + for key, value in allowances.items(): + + # Numeric weekday index from the original GUI. + try: + numeric_key = int( + key + ) + except ( + TypeError, + ValueError, + ): + numeric_key = None + + if ( + numeric_key is not None + and 0 <= numeric_key < len(DAY_NAMES) + ): + weekday = DAY_NAMES[ + numeric_key + ] + + else: + weekday = str( + key + ).lower().strip() + + converted[ + weekday + ] = value + + return normalize_allowances( + converted + ) + class IPCOperations: @staticmethod def get_users(): - with get_db() as db: - rows = db.execute( - """ - SELECT * - FROM users - ORDER BY username COLLATE NOCASE - """ - ).fetchall() + config = load_users_config() - return [dict(row) for row in rows] + users = [] - @staticmethod - def get_user(user_id): - with get_db() as db: - row = db.execute( - """ - SELECT * - FROM users - WHERE id = ? - """, - (user_id,), - ).fetchone() - - return dict(row) if row else None - - @staticmethod - def get_remaining_time(user_id): - with get_db() as db: - allowance_row = db.execute( - """ - SELECT COALESCE( - allowance_seconds, - 0 - ) AS total - FROM daily_allowances - WHERE user_id = ? - AND weekday = CAST( - strftime('%w', 'now', 'localtime') - AS INTEGER + for user in config.get( + "users", + [], + ): + try: + users.append( + _user_to_api( + user + ) ) - """, - (user_id,), - ).fetchone() + except ( + TypeError, + ValueError, + ): + continue - allowance = ( - allowance_row["total"] - if allowance_row - else 0 + users.sort( + key=lambda item: + item["username"].lower() + ) + + return users + + @staticmethod + def get_user( + user_id, + ): + user = find_user( + int(user_id) + ) + + if user is None: + return None + + return _user_to_api( + user + ) + + @staticmethod + def get_remaining_time( + user_id, + ): + user = find_user( + int(user_id) + ) + + if user is None: + return 0, 0 + + from datetime import datetime + + from .config import ( + python_weekday_to_name, + load_state, + ) + + weekday = python_weekday_to_name( + datetime.now().weekday() + ) + + allowances = normalize_allowances( + user.get( + "daily_allowance", + {}, ) + ) - usage_row = db.execute( - """ - SELECT COALESCE( - used_seconds, - 0 - ) AS total - FROM usage - WHERE user_id = ? - AND date = date('now', 'localtime') - """, - (user_id,), - ).fetchone() - - used = ( - usage_row["total"] - if usage_row - else 0 - ) - - remaining = max( - allowance - used, + allowance = allowances.get( + weekday, 0, ) - return remaining, allowance + username = str( + user.get( + "username", + "", + ) + ) - @staticmethod - def get_allowances(user_id): - with get_db() as db: - rows = db.execute( - """ - SELECT weekday, allowance_seconds - FROM daily_allowances - WHERE user_id = ? - """, - (user_id,), - ).fetchall() + today = ( + datetime.now() + .date() + .isoformat() + ) - return { - row["weekday"]: row["allowance_seconds"] - for row in rows - } + state = load_state() - @staticmethod - def get_access_windows(user_id): - with get_db() as db: - rows = db.execute( - """ - SELECT weekday, start_minute, end_minute - FROM access_windows - WHERE user_id = ? - ORDER BY weekday, start_minute - """, - (user_id,), - ).fetchall() + usage = state.get( + "usage", + {}, + ) - result = {} + used = 0 - for row in rows: - result.setdefault( - row["weekday"], - [], - ).append( - ( - row["start_minute"], - row["end_minute"], - ) + if isinstance( + usage, + dict, + ): + user_usage = usage.get( + username, + {}, ) - return result + if isinstance( + user_usage, + dict, + ): + try: + used = int( + user_usage.get( + today, + 0, + ) + ) + except ( + TypeError, + ValueError, + ): + used = 0 + + remaining = max( + 0, + allowance - used, + ) + + grant = get_remaining_grant_seconds( + int(user_id) + ) + + return ( + remaining + grant, + allowance, + ) + + @staticmethod + def get_allowances( + user_id, + ): + user = find_user( + int(user_id) + ) + + if user is None: + return {} + + return normalize_allowances( + user.get( + "daily_allowance", + {}, + ) + ) + + @staticmethod + def get_access_windows( + user_id, + ): + user = find_user( + int(user_id) + ) + + if user is None: + return {} + + return normalize_access_windows( + user.get( + "access_windows", + {}, + ) + ) @staticmethod def create_user( @@ -207,59 +432,70 @@ class IPCOperations: allowances, access_windows, ): - with get_db() as db: - cursor = db.execute( - """ - INSERT INTO users ( - username, - enabled - ) - VALUES (?, ?) - """, - ( - username, - 1 if enabled else 0, - ), + username = str( + username + ).strip() + + if not username: + raise ValueError( + "Username cannot be empty" ) - user_id = cursor.lastrowid + if not user_exists( + username + ): + raise ValueError( + f"Linux user does not exist: " + f"{username}" + ) - for weekday, seconds in allowances.items(): - db.execute( - """ - INSERT INTO daily_allowances ( - user_id, - weekday, - allowance_seconds - ) - VALUES (?, ?, ?) - """, - ( - user_id, - weekday, - seconds, - ), + config = load_users_config() + + for existing in config[ + "users" + ]: + if str( + existing.get( + "username", + "", + ) + ) == username: + raise ValueError( + f"User already configured: " + f"{username}" ) - for weekday, windows in access_windows.items(): - for start_minute, end_minute in windows: - db.execute( - """ - INSERT INTO access_windows ( - user_id, - weekday, - start_minute, - end_minute - ) - VALUES (?, ?, ?, ?) - """, - ( - user_id, - weekday, - start_minute, - end_minute, - ), - ) + users = config[ + "users" + ] + + user_id = next_user_id( + users + ) + + user = { + "id": user_id, + "username": username, + "enabled": bool( + enabled + ), + "daily_allowance": + _parse_allowances( + allowances + ), + "access_windows": + _parse_access_windows( + access_windows + ), + } + + users.append( + user + ) + + save_users_config( + config + ) return user_id @@ -270,120 +506,214 @@ class IPCOperations: allowances, access_windows, ): - with get_db() as db: - db.execute( - """ - UPDATE users - SET enabled = ? - WHERE id = ? - """, - ( - 1 if enabled else 0, - user_id, - ), + config, user, _ = ( + _find_user_or_raise( + user_id + ) + ) + + user[ + "enabled" + ] = bool( + enabled + ) + + user[ + "daily_allowance" + ] = _parse_allowances( + allowances + ) + + user[ + "access_windows" + ] = _parse_access_windows( + access_windows + ) + + save_users_config( + config + ) + + return True + + @staticmethod + def delete_user( + user_id, + ): + config, user, index = ( + _find_user_or_raise( + user_id + ) + ) + + username = str( + user.get( + "username", + "", + ) + ) + + from .config import ( + load_state, + save_state, + ) + + config["users"].pop( + index + ) + + save_users_config( + config + ) + + state = load_state() + + usage = state.get( + "usage", + {}, + ) + + if isinstance( + usage, + dict, + ): + usage.pop( + username, + None, ) - for weekday, seconds in allowances.items(): - db.execute( - """ - INSERT INTO daily_allowances ( - user_id, - weekday, - allowance_seconds + grants = state.get( + "temporary_grants", + [], + ) + + if isinstance( + grants, + list, + ): + state[ + "temporary_grants" + ] = [ + grant + for grant in grants + if str( + grant.get( + "username", + "", ) - VALUES (?, ?, ?) - ON CONFLICT(user_id, weekday) - DO UPDATE SET - allowance_seconds = - excluded.allowance_seconds - """, - ( - user_id, - weekday, - seconds, - ), ) + != username + ] - db.execute( - """ - DELETE FROM access_windows - WHERE user_id = ? - """, - (user_id,), - ) + save_state( + state + ) - for weekday, windows in access_windows.items(): - for start_minute, end_minute in windows: - db.execute( - """ - INSERT INTO access_windows ( - user_id, - weekday, - start_minute, - end_minute - ) - VALUES (?, ?, ?, ?) - """, - ( - user_id, - weekday, - start_minute, - end_minute, - ), - ) + return True @staticmethod - def delete_user(user_id): - with get_db() as db: - db.execute( - """ - DELETE FROM users - WHERE id = ? - """, - (user_id,), + def set_enabled( + user_id, + enabled, + ): + config, user, _ = ( + _find_user_or_raise( + user_id ) + ) - @staticmethod - def set_enabled(user_id, enabled): - with get_db() as db: - db.execute( - """ - UPDATE users - SET enabled = ? - WHERE id = ? - """, - ( - 1 if enabled else 0, - user_id, - ), - ) + user[ + "enabled" + ] = bool( + enabled + ) + + save_users_config( + config + ) + + return True @staticmethod def add_temporary_time( user_id, seconds, ): - with get_db() as db: - db.execute( - """ - INSERT INTO temporary_grants ( - user_id, - seconds, - remaining_seconds - ) - VALUES (?, ?, ?) - """, - ( - user_id, - seconds, - seconds, - ), + from datetime import ( + datetime, + timedelta, + ) + + user = find_user( + int(user_id) + ) + + if user is None: + raise ValueError( + f"User not found: {user_id}" ) + try: + seconds = int( + seconds + ) + except ( + TypeError, + ValueError, + ): + raise ValueError( + "Invalid temporary time" + ) + + if seconds <= 0: + raise ValueError( + "Temporary time must " + "be greater than zero" + ) + + username = str( + user.get( + "username", + "", + ) + ) + + from .config import ( + load_state, + save_state, + ) + + state = load_state() + + grants = state.setdefault( + "temporary_grants", + [], + ) + + grant = { + "username": username, + "seconds": seconds, + "remaining_seconds": seconds, + "created_at": + datetime.now().isoformat(), + "expires_at": ( + datetime.now() + + timedelta( + seconds=seconds + ) + ).isoformat(), + } + + grants.append( + grant + ) + + save_state( + state + ) + + return True -# ============================================================ -# Allowed IPC methods -# ============================================================ READ_METHODS = { "get_users", @@ -403,17 +733,18 @@ WRITE_METHODS = { } -# ============================================================ -# Request handling -# ============================================================ - def handle_request( request, pid, - uid, ): - method = request.get("method") - arguments = request.get("args", {}) + method = request.get( + "method" + ) + + arguments = request.get( + "args", + {}, + ) if method in READ_METHODS: operation = getattr( @@ -427,10 +758,14 @@ def handle_request( f"Unknown method: {method}" ) - return operation(**arguments) + return operation( + **arguments + ) if method in WRITE_METHODS: - if not authorize_write(pid, uid): + if not authorize_write( + pid + ): raise PermissionError( "Administrative authorization required" ) @@ -446,21 +781,23 @@ def handle_request( f"Unknown method: {method}" ) - return operation(**arguments) + return operation( + **arguments + ) raise RuntimeError( f"Unknown IPC method: {method}" ) -# ============================================================ -# Client handling -# ============================================================ - -def handle_client(connection): +def handle_client( + connection, +): try: - pid, uid, gid = get_peer_credentials( - connection + pid, uid, gid = ( + get_peer_credentials( + connection + ) ) data = bytearray() @@ -473,7 +810,9 @@ def handle_client(connection): if not chunk: break - data.extend(chunk) + data.extend( + chunk + ) if b"\n" in chunk: break @@ -481,20 +820,23 @@ def handle_client(connection): if not data: return - line = bytes(data).split( + line = bytes( + data + ).split( b"\n", 1, )[0] request = json.loads( - line.decode("utf-8") + line.decode( + "utf-8" + ) ) try: result = handle_request( request, pid, - uid, ) response = { @@ -506,14 +848,15 @@ def handle_client(connection): response = { "ok": False, "error": str(exc), - "error_type": "authorization", + "error_type": + "authorization", } - except sqlite3.IntegrityError as exc: + except ValueError as exc: response = { "ok": False, "error": str(exc), - "error_type": "database", + "error_type": "validation", } except Exception as exc: @@ -526,17 +869,25 @@ def handle_client(connection): payload = ( json.dumps( response, - separators=(",", ":"), - ).encode("utf-8") + separators=( + ",", + ":", + ), + ).encode( + "utf-8" + ) + b"\n" ) - connection.sendall(payload) + connection.sendall( + payload + ) except Exception: try: connection.sendall( - b'{"ok":false,"error":"Invalid request",' + b'{"ok":false,' + b'"error":"Invalid request",' b'"error_type":"server"}\n' ) except Exception: @@ -546,10 +897,6 @@ def handle_client(connection): connection.close() -# ============================================================ -# IPC server -# ============================================================ - class IPCServer: def __init__( @@ -562,13 +909,12 @@ class IPCServer: self.server_socket = None self._thread = None - self._stop_event = threading.Event() + self._stop_event = ( + threading.Event() + ) def start(self): - if ( - self._thread is not None - and self._thread.is_alive() - ): + if self._thread is not None: return self.socket_path.parent.mkdir( @@ -578,6 +924,7 @@ class IPCServer: try: self.socket_path.unlink() + except FileNotFoundError: pass @@ -587,7 +934,9 @@ class IPCServer: ) self.server_socket.bind( - str(self.socket_path) + str( + self.socket_path + ) ) os.chmod( @@ -595,7 +944,9 @@ class IPCServer: 0o666, ) - self.server_socket.listen(16) + self.server_socket.listen( + 16 + ) self._stop_event.clear() diff --git a/app/main.py b/app/main.py index 03bc2ff..478ffed 100644 --- a/app/main.py +++ b/app/main.py @@ -1,25 +1,24 @@ -import time - -from .database import initialize_database -from .scheduler import start_scheduler, stop_scheduler -from .ipc import start_ipc_server, stop_ipc_server +from .ipc import IPCServer +from .scheduler import Scheduler def main(): - initialize_database() - start_ipc_server() - start_scheduler() + scheduler = Scheduler() + ipc = IPCServer() + + scheduler.start() + ipc.start() try: while True: - time.sleep(60) + ipc._stop_event.wait(3600) except KeyboardInterrupt: pass finally: - stop_scheduler() - stop_ipc_server() + ipc.stop() + scheduler.stop() if __name__ == "__main__": diff --git a/app/models.py b/app/models.py deleted file mode 100644 index 576ca63..0000000 --- a/app/models.py +++ /dev/null @@ -1,34 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - - -@dataclass -class User: - id: int - username: str - enabled: bool - - -@dataclass -class DailyAllowance: - user_id: int - weekday: int - allowance_seconds: int - - -@dataclass -class AccessWindow: - id: int - user_id: int - weekday: int - start_minute: int - end_minute: int - - -@dataclass -class TemporaryGrant: - id: int - user_id: int - seconds: int - expires_at: Optional[str] - consumed: bool diff --git a/app/scheduler.py b/app/scheduler.py index 8c7876a..b7ecec8 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -1,33 +1,34 @@ +from __future__ import annotations + import threading import time -from datetime import datetime +import traceback from .enforcement import ( enforce_all_users, - record_usage, - get_user_policy, - consume_grant_seconds, ) -CHECK_INTERVAL = 5 - - class Scheduler: def __init__( self, - interval: int = CHECK_INTERVAL, + interval_seconds: float = 1.0, ): - self.interval = interval + self.interval_seconds = max( + 0.1, + float( + interval_seconds + ), + ) + + self._stop_event = ( + threading.Event() + ) 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() @@ -45,139 +46,56 @@ class Scheduler: self._thread.start() def stop(self): - self._stop_event.set() if self._thread is not None: self._thread.join( - timeout=self.interval + 2 + timeout=3 ) + self._thread = None + + def run_once(self): + return enforce_all_users() + def _run(self): + while not self._stop_event.is_set(): + started = time.monotonic() - # Evaluate immediately when the - # application starts. - self._tick() + try: + self.run_once() - while not self._stop_event.wait( - self.interval - ): - self._tick() + except Exception: + traceback.print_exc() - def _tick(self): - - now = time.monotonic() - - results = enforce_all_users() - - for result in results: - - user_id = result["user_id"] - - if not result["logged_in"]: - self._last_usage_update.pop( - user_id, - None, - ) - continue - - if not result["allowed"]: - self._last_usage_update.pop( - user_id, - None, - ) - continue - - previous = ( - self._last_usage_update.get( - user_id - ) + elapsed = ( + time.monotonic() + - started ) - self._last_usage_update[user_id] = now - - if previous is None: - continue - - elapsed = int( - now - previous + remaining = max( + 0.0, + self.interval_seconds + - elapsed, ) - if elapsed <= 0: - continue - - self._record_allowed_usage( - user_id, - elapsed, - ) - - def _record_allowed_usage( - self, - user_id: int, - seconds: int, - ): - - if seconds <= 0: - return - - weekday = datetime.now().weekday() - - ( - allowance_seconds, - usage_seconds, - windows, - grant_seconds, - ) = 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 - ) - - if total_usage <= 0: - return - - record_usage( - user_id, - total_usage, - ) - - if grant_usage > 0: - - consume_grant_seconds( - user_id, - grant_usage, + self._stop_event.wait( + remaining ) -scheduler = Scheduler() +_scheduler = Scheduler( + interval_seconds=1.0 +) def start_scheduler(): - scheduler.start() + _scheduler.start() def stop_scheduler(): - scheduler.stop() + _scheduler.stop() + + +def run_scheduler_once(): + return _scheduler.run_once() diff --git a/app/users.py b/app/users.py index bbb77b2..9a37e1a 100644 --- a/app/users.py +++ b/app/users.py @@ -1,59 +1,159 @@ +from __future__ import annotations + import pwd import subprocess -def linux_user_exists(username: str) -> bool: +def user_exists( + username: str, +) -> bool: try: - pwd.getpwnam(username) + pwd.getpwnam( + username + ) return True except KeyError: return False -def get_uid(username: str) -> int: - return pwd.getpwnam(username).pw_uid - - -def is_locked(username: str) -> bool: - result = subprocess.run( - ["passwd", "-S", username], +def _run( + command: list[str], +) -> subprocess.CompletedProcess: + return subprocess.run( + command, capture_output=True, text=True, check=False, ) + +def is_locked( + username: str, +) -> bool: + if not user_exists(username): + return False + + result = _run( + [ + "passwd", + "-S", + username, + ] + ) + if result.returncode != 0: return False - parts = result.stdout.split() + parts = result.stdout.strip().split() if len(parts) < 2: return False - return parts[1] == "L" + status = parts[1].upper() + + return status.startswith("L") -def lock_user(username: str): - subprocess.run( - ["loginctl", "terminate-user", username], - check=False, +def lock_user( + username: str, +) -> None: + if not user_exists(username): + raise RuntimeError( + f"Linux user does not exist: {username}" + ) + + result = _run( + [ + "passwd", + "-l", + username, + ] ) - subprocess.run( - ["passwd", "-l", username], - check=True, + if result.returncode != 0: + error = ( + result.stderr.strip() + or result.stdout.strip() + or "unknown error" + ) + + raise RuntimeError( + f"Unable to lock {username}: {error}" + ) + + +def unlock_user( + username: str, +) -> None: + if not user_exists(username): + raise RuntimeError( + f"Linux user does not exist: {username}" + ) + + result = _run( + [ + "passwd", + "-u", + username, + ] ) + if result.returncode != 0: + error = ( + result.stderr.strip() + or result.stdout.strip() + or "unknown error" + ) -def unlock_user(username: str): - subprocess.run( - ["passwd", "-u", username], - check=True, + raise RuntimeError( + f"Unable to unlock {username}: {error}" + ) + + +def terminate_user( + username: str, +) -> None: + if not user_exists(username): + return + + result = _run( + [ + "loginctl", + "terminate-user", + username, + ] ) + if result.returncode != 0: + error = ( + result.stderr.strip() + or result.stdout.strip() + ) -def terminate_user(username: str): - subprocess.run( - ["loginctl", "terminate-user", username], - check=False, - ) + if error: + raise RuntimeError( + f"Unable to terminate " + f"{username}: {error}" + ) + + +def get_uid( + username: str, +) -> int | None: + try: + return pwd.getpwnam( + username + ).pw_uid + except KeyError: + return None + + +def get_home( + username: str, +) -> str | None: + try: + return pwd.getpwnam( + username + ).pw_dir + except KeyError: + return None diff --git a/config/state.json b/config/state.json new file mode 100644 index 0000000..fe21b13 --- /dev/null +++ b/config/state.json @@ -0,0 +1,4 @@ +{ + "usage": {}, + "temporary_grants": [] +} \ No newline at end of file diff --git a/config/users.yaml b/config/users.yaml new file mode 100644 index 0000000..026a2f8 --- /dev/null +++ b/config/users.yaml @@ -0,0 +1,28 @@ +users: +- id: 1 + username: testuser1 + enabled: true + daily_allowance: + sunday: 0 + monday: 0 + tuesday: 0 + wednesday: 0 + thursday: 600 + friday: 0 + saturday: 0 + access_windows: + thursday: + - start: '16:00' + end: '21:00' +- id: 2 + username: testuser2 + enabled: false + daily_allowance: + sunday: 0 + monday: 0 + tuesday: 0 + wednesday: 0 + thursday: 0 + friday: 0 + saturday: 0 + access_windows: {} diff --git a/data/.gitkeep b/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/desktop/client.py b/desktop/client.py index 6b2d399..bf58018 100644 --- a/desktop/client.py +++ b/desktop/client.py @@ -1,265 +1,933 @@ import json import socket -from pathlib import Path -SOCKET_PATH = Path("/run/parental-control.sock") -SOCKET_TIMEOUT = 10 +SOCKET_PATH = "/run/parental-control.sock" + + +WEEKDAY_NAMES = ( + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", +) class IPCError(Exception): - """Base exception for IPC errors.""" + pass -class DatabaseError(IPCError): - """Raised when a database operation fails.""" class AuthorizationError(IPCError): - """Raised when an administrative operation is denied.""" + pass + + +class DatabaseError(IPCError): + pass + + +def _gui_to_named_weekdays( + data, +): + """ + Convert the original GUI's numeric weekday + keys into the named weekday format used by + the daemon/configuration. + + GUI: + 0 = sunday + 1 = monday + 2 = tuesday + 3 = wednesday + 4 = thursday + 5 = friday + 6 = saturday + + Config: + sunday + monday + ... + saturday + """ + + if not isinstance( + data, + dict, + ): + return {} + + converted = {} + + for key, value in data.items(): + + try: + numeric_key = int( + key + ) + except ( + TypeError, + ValueError, + ): + numeric_key = None + + if ( + numeric_key is not None + and 0 <= numeric_key < len( + WEEKDAY_NAMES + ) + ): + weekday = WEEKDAY_NAMES[ + numeric_key + ] + + else: + weekday = str( + key + ).lower().strip() + + converted[ + weekday + ] = value + + return converted + + +def _time_to_minutes( + value, +): + """ + Convert a time value into minutes since + midnight. + + Accepted formats include: + + "16:00" -> 960 + "21:00" -> 1260 + + Integer values are treated as minutes. + """ + + if isinstance( + value, + str, + ): + value = value.strip() + + parts = value.split( + ":" + ) + + if len(parts) == 2: + try: + hours = int( + parts[0] + ) + minutes = int( + parts[1] + ) + + if ( + 0 <= hours <= 23 + and 0 <= minutes <= 59 + ): + return ( + hours * 60 + + minutes + ) + + except ( + TypeError, + ValueError, + ): + pass + + try: + return int( + value + ) + + except ( + TypeError, + ValueError, + ): + return None + + try: + return int( + value + ) + + except ( + TypeError, + ValueError, + ): + return None + + +def _minutes_to_time( + value, +): + """ + Convert minutes since midnight into the + HH:MM string format used by the backend. + """ + + try: + total_minutes = int( + value + ) + except ( + TypeError, + ValueError, + ): + return None + + if not ( + 0 <= total_minutes < 24 * 60 + ): + return None + + hours = ( + total_minutes // 60 + ) + + minutes = ( + total_minutes % 60 + ) + + return ( + f"{hours:02d}:" + f"{minutes:02d}" + ) + + +def _gui_to_named_access_windows( + data, +): + """ + Convert access windows from the format + used by the original GUI into the format + expected by the daemon. + + GUI format: + + { + 4: [ + [960, 1260] + ] + } + + Backend format: + + { + "thursday": [ + { + "start": "16:00", + "end": "21:00" + } + ] + } + """ + + if not isinstance( + data, + dict, + ): + return {} + + converted = {} + + for key, windows in data.items(): + + try: + numeric_key = int( + key + ) + except ( + TypeError, + ValueError, + ): + numeric_key = None + + if ( + numeric_key is not None + and 0 <= numeric_key < len( + WEEKDAY_NAMES + ) + ): + weekday = WEEKDAY_NAMES[ + numeric_key + ] + + else: + weekday = str( + key + ).lower().strip() + + if not isinstance( + windows, + list, + ): + continue + + normalized_windows = [] + + for window in windows: + + if isinstance( + window, + dict, + ): + start = window.get( + "start" + ) + end = window.get( + "end" + ) + + elif isinstance( + window, + (list, tuple), + ) and len(window) >= 2: + + start = window[0] + end = window[1] + + else: + continue + + start_time = ( + _minutes_to_time( + start + ) + ) + + end_time = ( + _minutes_to_time( + end + ) + ) + + if ( + start_time is None + or end_time is None + ): + continue + + normalized_windows.append( + { + "start": start_time, + "end": end_time, + } + ) + + if normalized_windows: + converted[ + weekday + ] = normalized_windows + + return converted + + +def _named_to_gui_allowances( + data, +): + """ + Convert named weekday allowance keys from + the daemon into numeric weekday keys expected + by the original GUI. + """ + + if not isinstance( + data, + dict, + ): + return {} + + converted = {} + + for index, weekday in enumerate( + WEEKDAY_NAMES + ): + + value = data.get( + weekday, + 0, + ) + + try: + value = int( + value + ) + except ( + TypeError, + ValueError, + ): + value = 0 + + converted[ + index + ] = max( + 0, + value, + ) + + return converted + + +def _named_to_gui_access_windows( + data, +): + """ + Convert access windows returned by the + daemon into the exact representation expected + by the original GUI. + + Backend: + + { + "thursday": [ + { + "start": "16:00", + "end": "21:00" + } + ] + } + + GUI: + + { + 4: [ + [960, 1260] + ] + } + """ + + if not isinstance( + data, + dict, + ): + return {} + + converted = {} + + for index, weekday in enumerate( + WEEKDAY_NAMES + ): + + if weekday not in data: + continue + + windows = data[ + weekday + ] + + if not isinstance( + windows, + list, + ): + continue + + gui_windows = [] + + for window in windows: + + if not isinstance( + window, + dict, + ): + continue + + start = _time_to_minutes( + window.get( + "start" + ) + ) + + end = _time_to_minutes( + window.get( + "end" + ) + ) + + if ( + start is None + or end is None + ): + continue + + gui_windows.append( + [ + start, + end, + ] + ) + + if gui_windows: + converted[ + index + ] = gui_windows + + return converted class IPCClient: - @staticmethod - def call(method, **arguments): + def __init__( + self, + socket_path=SOCKET_PATH, + ): + self.socket_path = socket_path + + def _request( + self, + method, + args=None, + ): request = { "method": method, - "args": arguments, + "args": args or {}, } - payload = ( - json.dumps( - request, - separators=(",", ":"), - ).encode("utf-8") - + b"\n" - ) - - data = bytearray() - try: with socket.socket( socket.AF_UNIX, socket.SOCK_STREAM, - ) as connection: + ) as sock: - connection.settimeout( - SOCKET_TIMEOUT + sock.connect( + self.socket_path ) - connection.connect( - str(SOCKET_PATH) + payload = json.dumps( + request + ).encode( + "utf-8" ) - connection.sendall( + sock.sendall( payload ) + sock.shutdown( + socket.SHUT_WR + ) + + chunks = [] + while True: - chunk = connection.recv( - 65536 + chunk = sock.recv( + 4096 ) if not chunk: break - data.extend(chunk) - - if b"\n" in chunk: - break - - except FileNotFoundError: - raise IPCError( - "Parental control service is not running." - ) - - except ConnectionRefusedError: - raise IPCError( - "Could not connect to the parental control service." - ) - - except TimeoutError: - raise IPCError( - "The parental control service did not respond." - ) + chunks.append( + chunk + ) except OSError as exc: raise IPCError( - f"Could not connect to the parental control service: {exc}" - ) - - if not data: - raise IPCError( - "The parental control service returned no response." - ) - - line = bytes(data).split( - b"\n", - 1, - )[0] + str(exc) + ) from exc try: response = json.loads( - line.decode("utf-8") + b"".join( + chunks + ).decode( + "utf-8" + ) ) except ( - UnicodeDecodeError, json.JSONDecodeError, - ): + UnicodeDecodeError, + ) as exc: raise IPCError( - "The parental control service returned an invalid response." - ) + "Invalid response from " + "parental-control daemon" + ) from exc - if response.get("ok"): - return response.get("result") + if response.get( + "ok" + ): + return response.get( + "result" + ) error = response.get( "error", "Unknown error", ) - if response.get("error_type") == "authorization": - raise AuthorizationError(error) + if response.get( + "error_type" + ) == "authorization": + raise AuthorizationError( + error + ) - if response.get("error_type") == "database": - raise DatabaseError(error) + if response.get( + "error_type" + ) == "database": + raise DatabaseError( + error + ) - raise IPCError(error) + raise IPCError( + error + ) - -class Database: - """ - Compatibility interface for the GUI. - - The GUI keeps using Database.* methods, but all operations - now go through the root parental-control daemon instead of - accessing SQLite directly. - """ - - @staticmethod - def get_users(): - return IPCClient.call( + def get_users( + self, + ): + return self._request( "get_users" ) - @staticmethod - def get_user(user_id): - return IPCClient.call( + def get_user( + self, + user_id, + ): + return self._request( "get_user", - user_id=user_id, + { + "user_id": int( + user_id + ), + }, ) - @staticmethod - def get_remaining_time(user_id): - result = IPCClient.call( + def get_remaining_time( + self, + user_id, + ): + return self._request( "get_remaining_time", - user_id=user_id, + { + "user_id": int( + user_id + ), + }, ) - return ( - result[0], - result[1], - ) - - @staticmethod - def get_allowances(user_id): - result = IPCClient.call( + def get_allowances( + self, + user_id, + ): + result = self._request( "get_allowances", - user_id=user_id, + { + "user_id": int( + user_id + ), + }, ) - return { - int(key): value - for key, value in result.items() - } + return _named_to_gui_allowances( + result + ) - @staticmethod - def get_access_windows(user_id): - result = IPCClient.call( + def get_access_windows( + self, + user_id, + ): + result = self._request( "get_access_windows", - user_id=user_id, + { + "user_id": int( + user_id + ), + }, ) - return { - int(key): [ - tuple(window) - for window in windows - ] - for key, windows in result.items() - } + return _named_to_gui_access_windows( + result + ) - @staticmethod def create_user( + self, username, enabled, allowances, access_windows, ): - return IPCClient.call( + return self._request( "create_user", - username=username, - enabled=enabled, - allowances={ - str(key): value - for key, value in allowances.items() - }, - access_windows={ - str(key): [ - list(window) - for window in windows - ] - for key, windows in access_windows.items() + { + "username": username, + "enabled": bool( + enabled + ), + "allowances": ( + _gui_to_named_weekdays( + allowances + ) + ), + "access_windows": ( + _gui_to_named_access_windows( + access_windows + ) + ), }, ) - @staticmethod def update_user( + self, user_id, enabled, allowances, access_windows, ): - return IPCClient.call( + return self._request( "update_user", - user_id=user_id, - enabled=enabled, - allowances={ - str(key): value - for key, value in allowances.items() - }, - access_windows={ - str(key): [ - list(window) - for window in windows - ] - for key, windows in access_windows.items() + { + "user_id": int( + user_id + ), + "enabled": bool( + enabled + ), + "allowances": ( + _gui_to_named_weekdays( + allowances + ) + ), + "access_windows": ( + _gui_to_named_access_windows( + access_windows + ) + ), }, ) - @staticmethod - def delete_user(user_id): - return IPCClient.call( + def delete_user( + self, + user_id, + ): + return self._request( "delete_user", - user_id=user_id, + { + "user_id": int( + user_id + ), + }, ) - @staticmethod def set_enabled( + self, user_id, enabled, ): - return IPCClient.call( + return self._request( "set_enabled", - user_id=user_id, - enabled=enabled, + { + "user_id": int( + user_id + ), + "enabled": bool( + enabled + ), + }, + ) + + def set_allowance( + self, + user_id, + weekday, + seconds, + ): + return self._request( + "set_allowance", + { + "user_id": int( + user_id + ), + "weekday": str( + weekday + ).lower(), + "seconds": int( + seconds + ), + }, + ) + + def set_access_windows( + self, + user_id, + weekday, + windows, + ): + return self._request( + "set_access_windows", + { + "user_id": int( + user_id + ), + "weekday": str( + weekday + ).lower(), + "windows": windows, + }, ) - @staticmethod def add_temporary_time( + self, user_id, seconds, ): - return IPCClient.call( + return self._request( "add_temporary_time", - user_id=user_id, - seconds=seconds, + { + "user_id": int( + user_id + ), + "seconds": int( + seconds + ), + }, + ) + + +class Database: + """ + Compatibility interface for the original GUI. + + The original PySide6 GUI calls Database methods + directly on the class. These methods route + operations through the parental-control daemon. + """ + + _client = IPCClient() + + @classmethod + def get_users( + cls, + ): + return cls._client.get_users() + + @classmethod + def get_user( + cls, + user_id, + ): + return cls._client.get_user( + user_id + ) + + @classmethod + def get_remaining_time( + cls, + user_id, + ): + return cls._client.get_remaining_time( + user_id + ) + + @classmethod + def get_allowances( + cls, + user_id, + ): + return cls._client.get_allowances( + user_id + ) + + @classmethod + def get_access_windows( + cls, + user_id, + ): + return cls._client.get_access_windows( + user_id + ) + + @classmethod + def create_user( + cls, + username, + enabled, + allowances, + access_windows, + ): + return cls._client.create_user( + username, + enabled, + allowances, + access_windows, + ) + + @classmethod + def update_user( + cls, + user_id, + enabled, + allowances, + access_windows, + ): + return cls._client.update_user( + user_id, + enabled, + allowances, + access_windows, + ) + + @classmethod + def delete_user( + cls, + user_id, + ): + return cls._client.delete_user( + user_id + ) + + @classmethod + def set_enabled( + cls, + user_id, + enabled, + ): + return cls._client.set_enabled( + user_id, + enabled, + ) + + @classmethod + def set_allowance( + cls, + user_id, + weekday, + seconds, + ): + return cls._client.set_allowance( + user_id, + weekday, + seconds, + ) + + @classmethod + def set_access_windows( + cls, + user_id, + weekday, + windows, + ): + return cls._client.set_access_windows( + user_id, + weekday, + windows, + ) + + @classmethod + def add_temporary_time( + cls, + user_id, + seconds, + ): + return cls._client.add_temporary_time( + user_id, + seconds, ) diff --git a/install.sh b/install.sh index cfcacb3..dc4cd6b 100755 --- a/install.sh +++ b/install.sh @@ -17,7 +17,7 @@ PYTHON="$VENV_DIR/bin/python" SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME" DESKTOP_FILE="/usr/share/applications/$DESKTOP_ID" -POLKIT_ACTION="org.sarlink.parentalcontrol.modify" +POLKIT_ACTION="org.parentalcontrol.modify" POLKIT_SOURCE="$INSTALL_DIR/$POLKIT_ACTION.policy" POLKIT_DEST="/etc/polkit-1/actions/$POLKIT_ACTION.policy" @@ -117,16 +117,22 @@ info "Checking project files" REQUIRED_FILES=( "requirements.txt" "parental-control.service" + "$POLKIT_ACTION.policy" + "app/__init__.py" "app/main.py" - "app/database.py" + "app/config.py" "app/enforcement.py" "app/scheduler.py" "app/users.py" "app/ipc.py" + "desktop/__init__.py" "desktop/main.py" "desktop/client.py" + + "config/users.yaml" + "config/state.json" ) for file in "${REQUIRED_FILES[@]}"; do @@ -425,26 +431,20 @@ success "Source tree owned by $INSTALL_USER." # ============================================================ -# Prepare data directory +# Verify configuration # ============================================================ -info "Preparing application data" +info "Checking application configuration" -mkdir -p "$INSTALL_DIR/data" - -# Database is owned by the root service. -chown root:root "$INSTALL_DIR/data" -chmod 700 "$INSTALL_DIR/data" - -if [ -f "$INSTALL_DIR/data/parental-control.db" ]; then - chown root:root \ - "$INSTALL_DIR/data/parental-control.db" - - chmod 600 \ - "$INSTALL_DIR/data/parental-control.db" +if [ ! -f "$INSTALL_DIR/config/users.yaml" ]; then + error "users.yaml was not found." fi -success "Data directory secured." +if [ ! -f "$INSTALL_DIR/config/state.json" ]; then + error "state.json was not found." +fi + +success "YAML/JSON configuration files found." # ============================================================ @@ -502,7 +502,7 @@ if [ -f "$POLKIT_SOURCE" ]; then else - warning "Polkit policy file is not present yet." + warning "Polkit policy file is not present." warning "Expected: $POLKIT_SOURCE" warning "Skipping polkit policy installation." @@ -678,28 +678,6 @@ else fi -# ============================================================ -# Verify database -# ============================================================ - -info "Checking database" - -if [ -f "$INSTALL_DIR/data/parental-control.db" ]; then - - DB_OWNER="$(stat -c '%U:%G' "$INSTALL_DIR/data/parental-control.db")" - DB_MODE="$(stat -c '%a' "$INSTALL_DIR/data/parental-control.db")" - - echo " Owner: $DB_OWNER" - echo " Permissions: $DB_MODE" - -else - - warning "Database does not exist yet." - warning "The service should create it automatically." - -fi - - # ============================================================ # Final verification # ============================================================ @@ -747,6 +725,22 @@ else fi +if [ -f "$INSTALL_DIR/config/users.yaml" ]; then + echo " Users: OK" +else + echo " Users: FAILED" + CHECK_FAILED=1 +fi + + +if [ -f "$INSTALL_DIR/config/state.json" ]; then + echo " State: OK" +else + echo " State: FAILED" + CHECK_FAILED=1 +fi + + if [ "$CHECK_FAILED" -ne 0 ]; then error "One or more required installation checks failed." fi @@ -780,6 +774,10 @@ echo echo "IPC:" echo " $SOCKET_PATH" echo +echo "Configuration:" +echo " $INSTALL_DIR/config/users.yaml" +echo " $INSTALL_DIR/config/state.json" +echo echo "Installation:" echo " $INSTALL_DIR" echo diff --git a/org.parentalcontrol.modify.policy b/org.parentalcontrol.modify.policy new file mode 100644 index 0000000..8270f4b --- /dev/null +++ b/org.parentalcontrol.modify.policy @@ -0,0 +1,26 @@ + + + + + + + + + Modify Linux parental control settings + + + + Authentication is required to modify parental control settings. + + + + auth_admin + auth_admin + auth_admin + + + + + diff --git a/requirements.txt b/requirements.txt index 27c14d2..2e9e158 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -PySide6==6.11.2 +PySide6 +PyYAML