updated gui and enforcement
This commit is contained in:
+569
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+659
-308
File diff suppressed because it is too large
Load Diff
+10
-11
@@ -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__":
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
+126
-26
@@ -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}"
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["passwd", "-l", username],
|
||||
check=True,
|
||||
result = _run(
|
||||
[
|
||||
"passwd",
|
||||
"-l",
|
||||
username,
|
||||
]
|
||||
)
|
||||
|
||||
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):
|
||||
subprocess.run(
|
||||
["passwd", "-u", username],
|
||||
check=True,
|
||||
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"
|
||||
)
|
||||
|
||||
raise RuntimeError(
|
||||
f"Unable to unlock {username}: {error}"
|
||||
)
|
||||
|
||||
|
||||
def terminate_user(username: str):
|
||||
subprocess.run(
|
||||
["loginctl", "terminate-user", username],
|
||||
check=False,
|
||||
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()
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"usage": {},
|
||||
"temporary_grants": []
|
||||
}
|
||||
@@ -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: {}
|
||||
+823
-155
File diff suppressed because it is too large
Load Diff
+38
-40
@@ -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
|
||||
|
||||
@@ -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
@@ -1 +1,2 @@
|
||||
PySide6==6.11.2
|
||||
PySide6
|
||||
PyYAML
|
||||
|
||||
Reference in New Issue
Block a user