Initial native Linux parental control application

This commit is contained in:
2026-09-17 12:54:31 +05:00
commit 8b785b5480
15 changed files with 3262 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
data/*.db
data/*.db-*
.env
.idea/
.vscode/
+63
View File
@@ -0,0 +1,63 @@
# Linux Parental Control
A Linux parental-control system for managing Linux user access, daily time allowances, access windows, temporary grants, and automatic session enforcement.
> **Status:** Early development
## Features
- Per-user daily time allowances
- Different allowances for each day of the week
- Multiple access windows per day
- Temporary time grants
- Usage tracking
- Automatic session termination
- Automatic account locking
- Automatic account unlocking
- Reboot-safe enforcement
- Web-based administration
- SQLite database
- systemd service support
---
# Requirements
The application currently targets Linux systems using `systemd`.
You need:
- Linux
- Python 3
- `python-venv`
- `pip`
- `systemd`
- `sudo`
- `passwd`
- `loginctl`
- Git
The enforcement service requires **root privileges** because it manages other Linux users and their sessions.
---
# 1. Clone the Repository
Clone the repository:
```bash
git clone https://github.com/Alsantek-me/linux-user-timer.git
```
## 2. Make it executable
```bash
chmod +x install.sh
```
## 3. Run the install script
```bash
sudo ./install.sh
```
### You can access it via http://127.0.0.1:8765/admin
View File
+105
View File
@@ -0,0 +1,105 @@
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()
+455
View File
@@ -0,0 +1,455 @@
from datetime import datetime
import subprocess
from .database import get_db
from .users import (
lock_user,
unlock_user,
terminate_user,
is_locked,
)
def user_has_session(username: str) -> bool:
result = subprocess.run(
[
"loginctl",
"list-users",
"--no-legend",
],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return False
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 2 and 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
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"]
return (
allowance_seconds,
usage_seconds,
windows,
grant_seconds,
)
def is_inside_window(windows, minute: int) -> bool:
if not windows:
return True
for window in windows:
if (
window["start_minute"]
<= minute
< window["end_minute"]
):
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,
now,
),
).fetchone()
return row["total"]
def consume_grant_seconds(
user_id: int,
seconds: int,
):
if seconds <= 0:
return
now = datetime.now().isoformat()
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()
remaining = seconds
for grant in grants:
if remaining <= 0:
break
available = grant["remaining_seconds"]
consumed = min(
available,
remaining,
)
new_remaining = (
available - consumed
)
db.execute(
"""
UPDATE temporary_grants
SET remaining_seconds = ?,
consumed = ?
WHERE id = ?
""",
(
new_remaining,
1 if new_remaining <= 0 else 0,
grant["id"],
),
)
remaining -= consumed
def record_usage(
user_id: int,
seconds: int,
):
if seconds <= 0:
return
today = datetime.now().date().isoformat()
with get_db() as db:
row = db.execute(
"""
SELECT used_seconds
FROM usage
WHERE user_id = ?
AND date = ?
""",
(
user_id,
today,
),
).fetchone()
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,
),
)
def record_event(
user_id: int,
event_type: str,
details: str = "",
):
with get_db() as db:
db.execute(
"""
INSERT INTO events (
user_id,
event_type,
details
)
VALUES (?, ?, ?)
""",
(
user_id,
event_type,
details,
),
)
def evaluate_user(
user_id: int,
username: str,
):
now, weekday, minute = current_time()
(
allowance_seconds,
usage_seconds,
windows,
grant_seconds,
) = get_user_policy(
user_id,
weekday,
)
inside_window = is_inside_window(
windows,
minute,
)
allowance_remaining = max(
0,
allowance_seconds - usage_seconds,
)
total_remaining = (
allowance_remaining
+ grant_seconds
)
logged_in = user_has_session(
username
)
allowed_by_schedule = (
inside_window
and allowance_remaining > 0
)
allowed_by_grant = (
grant_seconds > 0
)
should_allow = (
allowed_by_schedule
or allowed_by_grant
)
locked = is_locked(username)
if should_allow:
if locked:
try:
unlock_user(username)
record_event(
user_id,
"auto_unlock",
"Access became available",
)
except Exception as exc:
record_event(
user_id,
"unlock_error",
str(exc),
)
else:
if logged_in:
terminate_user(username)
record_event(
user_id,
"session_terminated",
"Access is not currently permitted",
)
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),
)
return {
"user_id": user_id,
"username": username,
"timestamp": now.isoformat(),
"weekday": weekday,
"minute": minute,
"inside_window": inside_window,
"logged_in": logged_in,
"allowance_seconds": allowance_seconds,
"usage_seconds": usage_seconds,
"allowance_remaining": allowance_remaining,
"grant_seconds": grant_seconds,
"total_remaining": total_remaining,
"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()
results = []
for user in users:
try:
result = evaluate_user(
user["id"],
user["username"],
)
results.append(result)
except Exception as exc:
record_event(
user["id"],
"enforcement_error",
str(exc),
)
return results
+21
View File
@@ -0,0 +1,21 @@
import time
from .database import initialize_database
from .scheduler import start_scheduler, stop_scheduler
def main():
initialize_database()
start_scheduler()
try:
while True:
time.sleep(60)
except KeyboardInterrupt:
pass
finally:
stop_scheduler()
if __name__ == "__main__":
main()
+34
View File
@@ -0,0 +1,34 @@
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
+183
View File
@@ -0,0 +1,183 @@
import threading
import time
from datetime import datetime
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,
):
self.interval = interval
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()
):
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run,
name="parental-control-scheduler",
daemon=True,
)
self._thread.start()
def stop(self):
self._stop_event.set()
if self._thread is not None:
self._thread.join(
timeout=self.interval + 2
)
def _run(self):
# Evaluate immediately when the
# application starts.
self._tick()
while not self._stop_event.wait(
self.interval
):
self._tick()
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
)
)
self._last_usage_update[user_id] = now
if previous is None:
continue
elapsed = int(
now - previous
)
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,
)
scheduler = Scheduler()
def start_scheduler():
scheduler.start()
def stop_scheduler():
scheduler.stop()
+59
View File
@@ -0,0 +1,59 @@
import pwd
import subprocess
def linux_user_exists(username: str) -> bool:
try:
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],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return False
parts = result.stdout.split()
if len(parts) < 2:
return False
return parts[1] == "L"
def lock_user(username: str):
subprocess.run(
["loginctl", "terminate-user", username],
check=False,
)
subprocess.run(
["passwd", "-l", username],
check=True,
)
def unlock_user(username: str):
subprocess.run(
["passwd", "-u", username],
check=True,
)
def terminate_user(username: str):
subprocess.run(
["loginctl", "terminate-user", username],
check=False,
)
View File
View File
+2149
View File
File diff suppressed because it is too large Load Diff
Executable
+155
View File
@@ -0,0 +1,155 @@
#!/bin/bash
set -e
# ==========================================
# Linux Parental Control Installer
# ==========================================
if [ "$EUID" -ne 0 ]; then
echo "Please run with sudo:"
echo " sudo ./install.sh"
exit 1
fi
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_NAME="Parental Control"
DESKTOP_FILE="/usr/share/applications/parental-control.desktop"
SERVICE_FILE="/etc/systemd/system/parental-control.service"
echo
echo "=========================================="
echo " Linux Parental Control Installer"
echo "=========================================="
echo
echo "Installing from:"
echo " $INSTALL_DIR"
echo
# ------------------------------------------
# 1. System dependencies
# ------------------------------------------
echo "[1/6] Installing system dependencies"
pacman -S --needed --noconfirm \
python \
python-pip
echo
echo "System dependencies installed."
# ------------------------------------------
# 2. Python virtual environment
# ------------------------------------------
echo
echo "[2/6] Creating Python virtual environment"
if [ ! -d "$INSTALL_DIR/.venv" ]; then
python -m venv "$INSTALL_DIR/.venv"
else
echo "Virtual environment already exists."
fi
echo
echo "Virtual environment ready."
# ------------------------------------------
# 3. Python dependencies
# ------------------------------------------
echo
echo "[3/6] Installing Python packages"
"$INSTALL_DIR/.venv/bin/python" -m pip install \
--upgrade pip
"$INSTALL_DIR/.venv/bin/python" -m pip install \
-r "$INSTALL_DIR/requirements.txt"
echo
echo "Python packages installed."
# ------------------------------------------
# 4. Install systemd service
# ------------------------------------------
echo
echo "[4/6] Installing systemd service"
sed \
"s|%INSTALL_DIR%|$INSTALL_DIR|g" \
"$INSTALL_DIR/parental-control.service" \
> "$SERVICE_FILE"
chmod 644 "$SERVICE_FILE"
systemctl daemon-reload
systemctl enable parental-control.service
systemctl restart parental-control.service
echo
echo "Systemd service installed and started."
# ------------------------------------------
# 5. Install desktop application
# ------------------------------------------
echo
echo "[5/6] Installing application launcher"
cat > "$DESKTOP_FILE" <<EOF
[Desktop Entry]
Name=$APP_NAME
Comment=Linux Parental Control Manager
Exec=$INSTALL_DIR/.venv/bin/python -m desktop.main
Path=$INSTALL_DIR
Icon=preferences-system-parental-controls
Terminal=false
Type=Application
Categories=Settings;
StartupNotify=true
EOF
chmod 644 "$DESKTOP_FILE"
echo
echo "Desktop launcher installed:"
echo " $DESKTOP_FILE"
# ------------------------------------------
# 6. Update desktop database
# ------------------------------------------
echo
echo "[6/6] Updating application database"
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database /usr/share/applications
else
echo "update-desktop-database is not installed."
echo "Skipping desktop database update."
fi
# ------------------------------------------
# Verify installation
# ------------------------------------------
echo
echo "=========================================="
echo " Installation complete"
echo "=========================================="
echo
echo "Application:"
echo " $APP_NAME"
echo
echo "Launch it from your application menu."
echo "On your system:"
echo " Super + D"
echo
echo "Service:"
echo " systemctl status parental-control"
echo
echo "=========================================="
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Linux Parental Control
After=network.target
[Service]
Type=simple
WorkingDirectory=%INSTALL_DIR%
Environment="PATH=%INSTALL_DIR%/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin"
Environment="PARENTAL_CONTROL_ENFORCEMENT=1"
ExecStart=%INSTALL_DIR%/.venv/bin/python -m app.main
Restart=always
RestartSec=5
User=root
Group=root
[Install]
WantedBy=multi-user.target
+4
View File
@@ -0,0 +1,4 @@
PySide6==6.11.2
PySide6_Addons==6.11.2
PySide6_Essentials==6.11.2
shiboken6==6.11.2