From 77c00c4ed51d738debd30e539980c6a048ed5f5f Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 17 Sep 2026 16:14:19 +0500 Subject: [PATCH] user management fix --- app/ipc.py | 65 +++++++- desktop/client.py | 265 +++++++++++++++++++++++++++++++ desktop/main.py | 388 +--------------------------------------------- 3 files changed, 327 insertions(+), 391 deletions(-) create mode 100644 desktop/client.py diff --git a/app/ipc.py b/app/ipc.py index 2a25bdb..b2ae5a7 100644 --- a/app/ipc.py +++ b/app/ipc.py @@ -1,6 +1,8 @@ + import json import os import socket +import sqlite3 import struct import subprocess import threading @@ -16,6 +18,10 @@ BUFFER_SIZE = 65536 POLKIT_ACTION = "org.sarlink.parentalcontrol.modify" +# ============================================================ +# Peer credentials and authorization +# ============================================================ + def get_peer_credentials(connection): credentials = connection.getsockopt( socket.SOL_SOCKET, @@ -26,7 +32,19 @@ def get_peer_credentials(connection): return struct.unpack("3i", credentials) -def authorize_write(pid: int) -> bool: +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 + try: os.kill(pid, 0) except OSError: @@ -53,6 +71,10 @@ def authorize_write(pid: int) -> bool: return False +# ============================================================ +# Database operations +# ============================================================ + class IPCOperations: @staticmethod @@ -359,6 +381,10 @@ class IPCOperations: ) +# ============================================================ +# Allowed IPC methods +# ============================================================ + READ_METHODS = { "get_users", "get_user", @@ -377,7 +403,15 @@ WRITE_METHODS = { } -def handle_request(request, pid): +# ============================================================ +# Request handling +# ============================================================ + +def handle_request( + request, + pid, + uid, +): method = request.get("method") arguments = request.get("args", {}) @@ -396,7 +430,7 @@ def handle_request(request, pid): return operation(**arguments) if method in WRITE_METHODS: - if not authorize_write(pid): + if not authorize_write(pid, uid): raise PermissionError( "Administrative authorization required" ) @@ -419,6 +453,10 @@ def handle_request(request, pid): ) +# ============================================================ +# Client handling +# ============================================================ + def handle_client(connection): try: pid, uid, gid = get_peer_credentials( @@ -456,6 +494,7 @@ def handle_client(connection): result = handle_request( request, pid, + uid, ) response = { @@ -470,6 +509,13 @@ def handle_client(connection): "error_type": "authorization", } + except sqlite3.IntegrityError as exc: + response = { + "ok": False, + "error": str(exc), + "error_type": "database", + } + except Exception as exc: response = { "ok": False, @@ -500,6 +546,10 @@ def handle_client(connection): connection.close() +# ============================================================ +# IPC server +# ============================================================ + class IPCServer: def __init__( @@ -515,7 +565,10 @@ class IPCServer: self._stop_event = threading.Event() def start(self): - if self._thread is not None: + if ( + self._thread is not None + and self._thread.is_alive() + ): return self.socket_path.parent.mkdir( @@ -564,7 +617,9 @@ class IPCServer: pass if self._thread is not None: - self._thread.join(timeout=2) + self._thread.join( + timeout=2 + ) self._thread = None self.server_socket = None diff --git a/desktop/client.py b/desktop/client.py new file mode 100644 index 0000000..6b2d399 --- /dev/null +++ b/desktop/client.py @@ -0,0 +1,265 @@ +import json +import socket +from pathlib import Path + + +SOCKET_PATH = Path("/run/parental-control.sock") +SOCKET_TIMEOUT = 10 + + +class IPCError(Exception): + """Base exception for IPC errors.""" + +class DatabaseError(IPCError): + """Raised when a database operation fails.""" + +class AuthorizationError(IPCError): + """Raised when an administrative operation is denied.""" + + +class IPCClient: + @staticmethod + def call(method, **arguments): + request = { + "method": method, + "args": arguments, + } + + payload = ( + json.dumps( + request, + separators=(",", ":"), + ).encode("utf-8") + + b"\n" + ) + + data = bytearray() + + try: + with socket.socket( + socket.AF_UNIX, + socket.SOCK_STREAM, + ) as connection: + + connection.settimeout( + SOCKET_TIMEOUT + ) + + connection.connect( + str(SOCKET_PATH) + ) + + connection.sendall( + payload + ) + + while True: + chunk = connection.recv( + 65536 + ) + + 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." + ) + + 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] + + try: + response = json.loads( + line.decode("utf-8") + ) + + except ( + UnicodeDecodeError, + json.JSONDecodeError, + ): + raise IPCError( + "The parental control service returned an invalid response." + ) + + 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") == "database": + raise DatabaseError(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( + "get_users" + ) + + @staticmethod + def get_user(user_id): + return IPCClient.call( + "get_user", + user_id=user_id, + ) + + @staticmethod + def get_remaining_time(user_id): + result = IPCClient.call( + "get_remaining_time", + user_id=user_id, + ) + + return ( + result[0], + result[1], + ) + + @staticmethod + def get_allowances(user_id): + result = IPCClient.call( + "get_allowances", + user_id=user_id, + ) + + return { + int(key): value + for key, value in result.items() + } + + @staticmethod + def get_access_windows(user_id): + result = IPCClient.call( + "get_access_windows", + user_id=user_id, + ) + + return { + int(key): [ + tuple(window) + for window in windows + ] + for key, windows in result.items() + } + + @staticmethod + def create_user( + username, + enabled, + allowances, + access_windows, + ): + return IPCClient.call( + "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() + }, + ) + + @staticmethod + def update_user( + user_id, + enabled, + allowances, + access_windows, + ): + return IPCClient.call( + "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() + }, + ) + + @staticmethod + def delete_user(user_id): + return IPCClient.call( + "delete_user", + user_id=user_id, + ) + + @staticmethod + def set_enabled( + user_id, + enabled, + ): + return IPCClient.call( + "set_enabled", + user_id=user_id, + enabled=enabled, + ) + + @staticmethod + def add_temporary_time( + user_id, + seconds, + ): + return IPCClient.call( + "add_temporary_time", + user_id=user_id, + seconds=seconds, + ) diff --git a/desktop/main.py b/desktop/main.py index 8c4a048..5680c1d 100644 --- a/desktop/main.py +++ b/desktop/main.py @@ -1,6 +1,5 @@ import sys import pwd -import sqlite3 from PySide6.QtCore import Qt, QTime from PySide6.QtWidgets import ( @@ -26,389 +25,7 @@ from PySide6.QtWidgets import ( QWidget, ) -from app.database import DATABASE_PATH - - -# ============================================================ -# Database -# ============================================================ - -class Database: - - @staticmethod - def get_connection(): - connection = sqlite3.connect(DATABASE_PATH) - connection.row_factory = sqlite3.Row - connection.execute("PRAGMA foreign_keys = ON") - return connection - - @staticmethod - def get_users(): - if not DATABASE_PATH.exists(): - return [] - - connection = Database.get_connection() - - try: - return connection.execute( - """ - SELECT * - FROM users - ORDER BY username COLLATE NOCASE - """ - ).fetchall() - finally: - connection.close() - - @staticmethod - def get_user(user_id): - connection = Database.get_connection() - - try: - return connection.execute( - """ - SELECT * - FROM users - WHERE id = ? - """, - (user_id,), - ).fetchone() - finally: - connection.close() - - @staticmethod - def get_remaining_time(user_id): - if not DATABASE_PATH.exists(): - return 0, 0 - - connection = Database.get_connection() - - try: - allowance_row = connection.execute( - """ - SELECT COALESCE( - allowance_seconds, - 0 - ) AS total - FROM daily_allowances - WHERE user_id = ? - AND weekday = CAST( - strftime('%w', 'now', 'localtime') - AS INTEGER - ) - """, - (user_id,), - ).fetchone() - - allowance = ( - allowance_row["total"] - if allowance_row - else 0 - ) - - usage_row = connection.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, - 0, - ) - - return remaining, allowance - - finally: - connection.close() - - @staticmethod - def get_allowances(user_id): - connection = Database.get_connection() - - try: - rows = connection.execute( - """ - SELECT weekday, allowance_seconds - FROM daily_allowances - WHERE user_id = ? - """, - (user_id,), - ).fetchall() - - return { - row["weekday"]: row["allowance_seconds"] - for row in rows - } - - finally: - connection.close() - - @staticmethod - def get_access_windows(user_id): - connection = Database.get_connection() - - try: - rows = connection.execute( - """ - SELECT - weekday, - start_minute, - end_minute - FROM access_windows - WHERE user_id = ? - ORDER BY weekday, start_minute - """, - (user_id,), - ).fetchall() - - result = {} - - for row in rows: - result.setdefault( - row["weekday"], - [], - ).append( - ( - row["start_minute"], - row["end_minute"], - ) - ) - - return result - - finally: - connection.close() - - @staticmethod - def create_user( - username, - enabled, - allowances, - access_windows, - ): - connection = Database.get_connection() - - try: - cursor = connection.execute( - """ - INSERT INTO users ( - username, - enabled - ) - VALUES (?, ?) - """, - ( - username, - 1 if enabled else 0, - ), - ) - - user_id = cursor.lastrowid - - for weekday, seconds in allowances.items(): - connection.execute( - """ - INSERT INTO daily_allowances ( - user_id, - weekday, - allowance_seconds - ) - VALUES (?, ?, ?) - """, - ( - user_id, - weekday, - seconds, - ), - ) - - for weekday, windows in access_windows.items(): - for start_minute, end_minute in windows: - connection.execute( - """ - INSERT INTO access_windows ( - user_id, - weekday, - start_minute, - end_minute - ) - VALUES (?, ?, ?, ?) - """, - ( - user_id, - weekday, - start_minute, - end_minute, - ), - ) - - connection.commit() - - return user_id - - finally: - connection.close() - - @staticmethod - def update_user( - user_id, - enabled, - allowances, - access_windows, - ): - connection = Database.get_connection() - - try: - connection.execute( - """ - UPDATE users - SET enabled = ? - WHERE id = ? - """, - ( - 1 if enabled else 0, - user_id, - ), - ) - - for weekday, seconds in allowances.items(): - connection.execute( - """ - INSERT INTO daily_allowances ( - user_id, - weekday, - allowance_seconds - ) - VALUES (?, ?, ?) - ON CONFLICT(user_id, weekday) - DO UPDATE SET - allowance_seconds = - excluded.allowance_seconds - """, - ( - user_id, - weekday, - seconds, - ), - ) - - # Replace access windows completely. - connection.execute( - """ - DELETE FROM access_windows - WHERE user_id = ? - """, - (user_id,), - ) - - for weekday, windows in access_windows.items(): - for start_minute, end_minute in windows: - connection.execute( - """ - INSERT INTO access_windows ( - user_id, - weekday, - start_minute, - end_minute - ) - VALUES (?, ?, ?, ?) - """, - ( - user_id, - weekday, - start_minute, - end_minute, - ), - ) - - connection.commit() - - finally: - connection.close() - - @staticmethod - def delete_user(user_id): - connection = Database.get_connection() - - try: - connection.execute( - """ - DELETE FROM users - WHERE id = ? - """, - (user_id,), - ) - - connection.commit() - - finally: - connection.close() - - @staticmethod - def set_enabled(user_id, enabled): - connection = Database.get_connection() - - try: - connection.execute( - """ - UPDATE users - SET enabled = ? - WHERE id = ? - """, - ( - 1 if enabled else 0, - user_id, - ), - ) - - connection.commit() - - finally: - connection.close() - - @staticmethod - def add_temporary_time( - user_id, - seconds, - ): - connection = Database.get_connection() - - try: - connection.execute( - """ - INSERT INTO temporary_grants ( - user_id, - seconds, - remaining_seconds - ) - VALUES (?, ?, ?) - """, - ( - user_id, - seconds, - seconds, - ), - ) - - connection.commit() - - finally: - connection.close() - +from .client import Database, DatabaseError # ============================================================ # Linux users @@ -421,7 +38,6 @@ def linux_user_exists(username): except KeyError: return False - # ============================================================ # Helpers # ============================================================ @@ -1464,7 +1080,7 @@ class UserDialog(QDialog): access_windows, ) - except sqlite3.IntegrityError: + except DatabaseError: QMessageBox.warning( self,