updated gui and enforcement

This commit is contained in:
2026-09-17 20:41:35 +05:00
parent 77c00c4ed5
commit 47c292f4cd
15 changed files with 3065 additions and 1095 deletions
+569
View File
@@ -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
-105
View File
@@ -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()
+729 -282
View File
File diff suppressed because it is too large Load Diff
+664 -313
View File
File diff suppressed because it is too large Load Diff
+10 -11
View File
@@ -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__":
-34
View File
@@ -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
+44 -126
View File
@@ -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()
+127 -27
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
{
"usage": {},
"temporary_grants": []
}
+28
View File
@@ -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: {}
View File
+824 -156
View File
File diff suppressed because it is too large Load Diff
+38 -40
View File
@@ -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
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
<policyconfig>
<action id="org.parentalcontrol.modify">
<description>
Modify Linux parental control settings
</description>
<message>
Authentication is required to modify parental control settings.
</message>
<defaults>
<allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive>
<allow_active>auth_admin</allow_active>
</defaults>
</action>
</policyconfig>
+2 -1
View File
@@ -1 +1,2 @@
PySide6==6.11.2
PySide6
PyYAML