user management fix
This commit is contained in:
+60
-5
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
+2
-386
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user