first commit

This commit is contained in:
2026-09-17 03:43:51 +05:00
commit c4f01044e9
15 changed files with 3147 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
# 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.
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
+817
View File
@@ -0,0 +1,817 @@
from fastapi import (
FastAPI,
HTTPException,
Request,
Form,
)
from fastapi.responses import (
RedirectResponse,
)
from .scheduler import (
start_scheduler,
stop_scheduler,
)
from fastapi.templating import (
Jinja2Templates,
)
from pydantic import BaseModel
from .database import (
initialize_database,
get_db,
)
from .users import (
linux_user_exists,
lock_user,
unlock_user,
terminate_user,
is_locked,
)
app = FastAPI(
title="Parental Control",
version="0.1.0",
)
templates = Jinja2Templates(
directory="templates"
)
WEEKDAYS = [
(0, "Monday"),
(1, "Tuesday"),
(2, "Wednesday"),
(3, "Thursday"),
(4, "Friday"),
(5, "Saturday"),
(6, "Sunday"),
]
@app.on_event("startup")
def startup():
initialize_database()
start_scheduler()
@app.on_event("shutdown")
def shutdown():
stop_scheduler()
class AllowanceRequest(BaseModel):
weekday: int
seconds: int
class WindowRequest(BaseModel):
weekday: int
start_minute: int
end_minute: int
class GrantRequest(BaseModel):
seconds: int
def get_user(user_id: int):
with get_db() as db:
return db.execute(
"""
SELECT id, username, enabled
FROM users
WHERE id = ?
""",
(user_id,)
).fetchone()
@app.get("/")
def root():
return {
"application": "Parental Control",
"version": "0.1.0",
"status": "running"
}
@app.get("/api/users")
def list_users():
with get_db() as db:
rows = db.execute(
"""
SELECT id, username, enabled
FROM users
ORDER BY username
"""
).fetchall()
return [
{
"id": row["id"],
"username": row["username"],
"enabled": bool(row["enabled"]),
"locked": is_locked(row["username"])
}
for row in rows
]
@app.post("/api/users/{user_id}/lock")
def manually_lock(user_id: int):
row = get_user(user_id)
if row is None:
raise HTTPException(
status_code=404,
detail="User not found"
)
lock_user(row["username"])
return {
"username": row["username"],
"locked": True
}
@app.post("/api/users/{user_id}/unlock")
def manually_unlock(user_id: int):
row = get_user(user_id)
if row is None:
raise HTTPException(
status_code=404,
detail="User not found"
)
unlock_user(row["username"])
return {
"username": row["username"],
"locked": False
}
@app.post("/api/users/{user_id}/terminate")
def manually_terminate(user_id: int):
row = get_user(user_id)
if row is None:
raise HTTPException(
status_code=404,
detail="User not found"
)
terminate_user(row["username"])
return {
"username": row["username"],
"terminated": True
}
@app.post("/api/users/{user_id}/allowance")
def set_allowance(
user_id: int,
request: AllowanceRequest
):
if get_user(user_id) is None:
raise HTTPException(
status_code=404,
detail="User not found"
)
if request.weekday < 0 or request.weekday > 6:
raise HTTPException(
status_code=400,
detail="Invalid weekday"
)
if request.seconds < 0:
raise HTTPException(
status_code=400,
detail="Allowance cannot be negative"
)
with get_db() as db:
db.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,
request.weekday,
request.seconds
)
)
return {
"user_id": user_id,
"weekday": request.weekday,
"seconds": request.seconds
}
@app.post("/api/users/{user_id}/windows")
def add_window(
user_id: int,
request: WindowRequest
):
if get_user(user_id) is None:
raise HTTPException(
status_code=404,
detail="User not found"
)
if request.weekday < 0 or request.weekday > 6:
raise HTTPException(
status_code=400,
detail="Invalid weekday"
)
if request.start_minute < 0 or request.start_minute >= 1440:
raise HTTPException(
status_code=400,
detail="Invalid start time"
)
if request.end_minute < 0 or request.end_minute > 1440:
raise HTTPException(
status_code=400,
detail="Invalid end time"
)
if request.end_minute <= request.start_minute:
raise HTTPException(
status_code=400,
detail="End time must be after start time"
)
with get_db() as db:
db.execute(
"""
INSERT INTO access_windows
(
user_id,
weekday,
start_minute,
end_minute
)
VALUES (?, ?, ?, ?)
""",
(
user_id,
request.weekday,
request.start_minute,
request.end_minute
)
)
return {
"status": "created"
}
@app.delete("/api/windows/{window_id}")
def delete_window(window_id: int):
with get_db() as db:
cursor = db.execute(
"""
DELETE FROM access_windows
WHERE id = ?
""",
(window_id,)
)
if cursor.rowcount == 0:
raise HTTPException(
status_code=404,
detail="Window not found"
)
return {
"status": "deleted"
}
@app.post("/api/users/{user_id}/grant")
def grant_time(
user_id: int,
request: GrantRequest
):
if get_user(user_id) is None:
raise HTTPException(
status_code=404,
detail="User not found"
)
if request.seconds <= 0:
raise HTTPException(
status_code=400,
detail="Grant must be greater than zero"
)
with get_db() as db:
db.execute(
"""
INSERT INTO temporary_grants
(
user_id,
seconds,
remaining_seconds
)
VALUES (?, ?, ?)
""",
(
user_id,
request.seconds,
request.seconds
)
)
return {
"user_id": user_id,
"seconds": request.seconds
}
@app.get("/admin")
def admin_page(
request: Request
):
with get_db() as db:
users = db.execute(
"""
SELECT id, username, enabled
FROM users
ORDER BY username
"""
).fetchall()
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"users": users
}
)
@app.get("/admin/users/{user_id}")
def admin_user_page(
request: Request,
user_id: int
):
user = get_user(user_id)
if user is None:
raise HTTPException(
status_code=404,
detail="User not found"
)
with get_db() as db:
allowances = db.execute(
"""
SELECT weekday, allowance_seconds
FROM daily_allowances
WHERE user_id = ?
ORDER BY weekday
""",
(user_id,)
).fetchall()
windows = db.execute(
"""
SELECT
id,
weekday,
start_minute,
end_minute
FROM access_windows
WHERE user_id = ?
ORDER BY weekday, start_minute
""",
(user_id,)
).fetchall()
grants = db.execute(
"""
SELECT
id,
seconds,
created_at,
expires_at,
consumed
FROM temporary_grants
WHERE user_id = ?
ORDER BY id DESC
LIMIT 20
""",
(user_id,)
).fetchall()
allowance_map = {
row["weekday"]: row["allowance_seconds"]
for row in allowances
}
return templates.TemplateResponse(
request=request,
name="user.html",
context={
"user": user,
"locked": is_locked(user["username"]),
"weekdays": WEEKDAYS,
"allowances": allowance_map,
"windows": windows,
"grants": grants,
}
)
@app.post("/admin/users/{user_id}/allowance")
def admin_set_allowance(
user_id: int,
weekday: int = Form(...),
hours: int = Form(...),
minutes: int = Form(...),
):
if get_user(user_id) is None:
raise HTTPException(
status_code=404,
detail="User not found"
)
if weekday < 0 or weekday > 6:
raise HTTPException(
status_code=400,
detail="Invalid weekday"
)
if hours < 0 or minutes < 0 or minutes > 59:
raise HTTPException(
status_code=400,
detail="Invalid time"
)
seconds = (hours * 3600) + (minutes * 60)
with get_db() as db:
db.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
)
)
return RedirectResponse(
f"/admin/users/{user_id}",
status_code=303
)
@app.post("/admin/users/{user_id}/window")
def admin_add_window(
user_id: int,
weekday: int = Form(...),
start_time: str = Form(...),
end_time: str = Form(...),
):
if get_user(user_id) is None:
raise HTTPException(
status_code=404,
detail="User not found"
)
try:
start_hour, start_minute = map(
int,
start_time.split(":")
)
end_hour, end_minute = map(
int,
end_time.split(":")
)
except ValueError:
raise HTTPException(
status_code=400,
detail="Invalid time format"
)
start_total = (
start_hour * 60
+ start_minute
)
end_total = (
end_hour * 60
+ end_minute
)
if weekday < 0 or weekday > 6:
raise HTTPException(
status_code=400,
detail="Invalid weekday"
)
if start_total < 0 or start_total >= 1440:
raise HTTPException(
status_code=400,
detail="Invalid start time"
)
if end_total <= start_total:
raise HTTPException(
status_code=400,
detail="End time must be after start time"
)
with get_db() as db:
db.execute(
"""
INSERT INTO access_windows
(
user_id,
weekday,
start_minute,
end_minute
)
VALUES (?, ?, ?, ?)
""",
(
user_id,
weekday,
start_total,
end_total
)
)
return RedirectResponse(
f"/admin/users/{user_id}",
status_code=303
)
@app.post("/admin/windows/{window_id}/delete")
def admin_delete_window(
window_id: int
):
with get_db() as db:
row = db.execute(
"""
SELECT user_id
FROM access_windows
WHERE id = ?
""",
(window_id,)
).fetchone()
if row is None:
raise HTTPException(
status_code=404,
detail="Window not found"
)
user_id = row["user_id"]
db.execute(
"""
DELETE FROM access_windows
WHERE id = ?
""",
(window_id,)
)
return RedirectResponse(
f"/admin/users/{user_id}",
status_code=303
)
@app.post("/admin/users/{user_id}/grant")
def admin_grant_time(
user_id: int,
hours: int = Form(...),
minutes: int = Form(...),
):
if get_user(user_id) is None:
raise HTTPException(
status_code=404,
detail="User not found"
)
if hours < 0 or minutes < 0 or minutes > 59:
raise HTTPException(
status_code=400,
detail="Invalid time"
)
seconds = (
hours * 3600
+ minutes * 60
)
if seconds <= 0:
raise HTTPException(
status_code=400,
detail="Grant must be greater than zero"
)
with get_db() as db:
db.execute(
"""
INSERT INTO temporary_grants
(
user_id,
seconds,
remaining_seconds
)
VALUES (?, ?, ?)
""",
(
user_id,
seconds,
seconds
)
)
return RedirectResponse(
f"/admin/users/{user_id}",
status_code=303
)
@app.post("/admin/users/{user_id}/lock")
def admin_lock_user(
user_id: int
):
row = get_user(user_id)
if row is None:
raise HTTPException(
status_code=404,
detail="User not found"
)
lock_user(row["username"])
return RedirectResponse(
f"/admin/users/{user_id}",
status_code=303
)
@app.post("/admin/users/{user_id}/unlock")
def admin_unlock_user(
user_id: int
):
row = get_user(user_id)
if row is None:
raise HTTPException(
status_code=404,
detail="User not found"
)
unlock_user(row["username"])
return RedirectResponse(
f"/admin/users/{user_id}",
status_code=303
)
@app.post("/admin/users/{user_id}/terminate")
def admin_terminate_user(
user_id: int
):
row = get_user(user_id)
if row is None:
raise HTTPException(
status_code=404,
detail="User not found"
)
terminate_user(row["username"])
return RedirectResponse(
f"/admin/users/{user_id}",
status_code=303
)
@app.post("/admin/users/{user_id}/delete")
def delete_user(
user_id: int
):
with get_db() as db:
db.execute(
"""
DELETE FROM users
WHERE id = ?
""",
(user_id,)
)
return RedirectResponse(
"/admin",
status_code=303
)
@app.post("/admin/users")
def admin_add_user(
username: str = Form(...)
):
username = username.strip()
if not linux_user_exists(username):
raise HTTPException(
status_code=400,
detail="Linux user does not exist"
)
with get_db() as db:
existing = db.execute(
"""
SELECT id
FROM users
WHERE username = ?
""",
(username,)
).fetchone()
if existing is not None:
raise HTTPException(
status_code=400,
detail="User is already configured"
)
cursor = db.execute(
"""
INSERT INTO users(username)
VALUES(?)
""",
(username,)
)
user_id = cursor.lastrowid
for weekday in range(7):
db.execute(
"""
INSERT INTO daily_allowances
(
user_id,
weekday,
allowance_seconds
)
VALUES (?, ?, ?)
""",
(
user_id,
weekday,
0
)
)
return RedirectResponse(
"/admin",
status_code=303
)
+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
Executable
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
set -e
if [ "$EUID" -ne 0 ]; then
echo "Please run with sudo"
exit 1
fi
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "Installing from:"
echo "$INSTALL_DIR"
echo "[1/5] Installing dependencies"
pacman -S --needed --noconfirm python python-pip
echo "[2/5] Creating virtual environment"
if [ ! -d "$INSTALL_DIR/.venv" ]; then
python -m venv "$INSTALL_DIR/.venv"
fi
echo "[3/5] Installing Python packages"
"$INSTALL_DIR/.venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt"
echo "[4/5] Installing systemd service"
sed \
"s|%INSTALL_DIR%|$INSTALL_DIR|g" \
"$INSTALL_DIR/parental-control.service" \
> /etc/systemd/system/parental-control.service
systemctl daemon-reload
systemctl enable parental-control.service
systemctl restart parental-control.service
echo
echo "=================================="
echo "Parental Control installed"
echo
echo "Admin panel:"
echo "http://127.0.0.1:8765/admin"
echo
echo "Service status:"
echo "systemctl status parental-control"
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/uvicorn app.main:app --host 127.0.0.1 --port 8765
Restart=always
RestartSec=5
User=root
Group=root
[Install]
WantedBy=multi-user.target
+52
View File
@@ -0,0 +1,52 @@
# 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.
+16
View File
@@ -0,0 +1,16 @@
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
click==8.5.0
fastapi==0.141.1
h11==0.16.0
idna==3.19
Jinja2==3.1.6
MarkupSafe==3.0.3
pydantic==2.13.5
pydantic_core==2.46.5
python-multipart==0.0.32
starlette==1.6.0
typing-inspection==0.4.4
typing_extensions==4.16.0
uvicorn==0.53.0
+435
View File
@@ -0,0 +1,435 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>Parental Control</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background: #f4f5f7;
color: #1f2937;
}
header {
background: #111827;
color: white;
padding: 20px 30px;
}
header h1 {
margin: 0;
font-size: 24px;
}
main {
max-width: 1100px;
margin: 30px auto;
padding: 0 20px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
}
.topbar h2 {
margin: 0;
}
.card {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 15px;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08);
}
.user-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.username {
font-size: 20px;
font-weight: 600;
}
.status {
display: inline-block;
padding: 5px 10px;
border-radius: 20px;
font-size: 13px;
}
.status-enabled {
background: #dcfce7;
color: #166534;
}
.status-disabled {
background: #fee2e2;
color: #991b1b;
}
.actions {
display: flex;
gap: 10px;
margin-top: 15px;
flex-wrap: wrap;
}
button {
border: 0;
border-radius: 7px;
padding: 9px 15px;
cursor: pointer;
font-size: 14px;
}
.button-primary {
background: #2563eb;
color: white;
}
.button-danger {
background: #dc2626;
color: white;
}
.button-secondary {
background: #e5e7eb;
color: #111827;
}
.add-user {
margin-top: 30px;
}
.add-user form {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
input {
border: 1px solid #d1d5db;
border-radius: 7px;
padding: 9px 12px;
font-size: 14px;
}
.empty {
color: #6b7280;
}
</style>
</head>
<body>
<header>
<h1>
Parental Control
</h1>
</header>
<main>
<div class="topbar">
<h2>
Users
</h2>
</div>
{% if users %}
{% for user in users %}
<div class="card">
<div class="user-header">
<div>
<div class="username">
{{ user.username }}
</div>
<div>
Linux user ID:
{{ user.id }}
</div>
</div>
{% if user.enabled %}
<span class="status status-enabled">
Enabled
</span>
{% else %}
<span class="status status-disabled">
Disabled
</span>
{% endif %}
</div>
<div class="actions">
<a
href="/admin/users/{{ user.id }}"
>
<button
class="button-primary"
type="button"
>
Manage
</button>
</a>
<form
method="post"
action="/admin/users/{{ user.id }}/delete"
>
<button
class="button-danger"
type="submit"
>
Delete
</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<div class="card empty">
No users configured yet.
</div>
{% endif %}
<div class="card add-user">
<h2>
Add User
</h2>
<p>
Add an existing Linux account to
parental control.
</p>
<form
method="post"
action="/admin/users"
>
<input
type="text"
name="username"
placeholder="Linux username"
required
>
<button
class="button-primary"
type="submit"
>
Add User
</button>
</form>
</div>
</main>
</body>
</html>
+859
View File
@@ -0,0 +1,859 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>
Manage {{ user.username }}
</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background: #f4f5f7;
color: #1f2937;
}
header {
background: #111827;
color: white;
padding: 20px 30px;
}
header h1 {
margin: 0;
font-size: 24px;
}
main {
max-width: 1100px;
margin: 30px auto;
padding: 0 20px;
}
.back {
display: inline-block;
margin-bottom: 20px;
color: #2563eb;
text-decoration: none;
font-size: 14px;
}
.card {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow:
0 2px 8px rgba(
0,
0,
0,
0.08
);
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.username {
font-size: 28px;
font-weight: 700;
}
.status {
display: inline-block;
padding: 6px 12px;
border-radius: 20px;
font-size: 13px;
}
.status-locked {
background: #fee2e2;
color: #991b1b;
}
.status-unlocked {
background: #dcfce7;
color: #166534;
}
h2 {
margin-top: 0;
}
h3 {
margin-bottom: 8px;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 20px;
}
button {
border: 0;
border-radius: 7px;
padding: 9px 15px;
cursor: pointer;
font-size: 14px;
}
.primary {
background: #2563eb;
color: white;
}
.danger {
background: #dc2626;
color: white;
}
.warning {
background: #d97706;
color: white;
}
.secondary {
background: #e5e7eb;
color: #111827;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 12px;
border-bottom: 1px solid #e5e7eb;
}
th {
font-size: 13px;
color: #6b7280;
}
input,
select {
border: 1px solid #d1d5db;
border-radius: 7px;
padding: 8px 10px;
font-size: 14px;
background: white;
}
input[type="number"] {
width: 80px;
}
input[type="time"] {
width: 130px;
}
.inline-form {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.window-form {
display: grid;
grid-template-columns:
150px
130px
130px
auto;
gap: 10px;
align-items: center;
}
.window {
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
padding: 12px 0;
border-bottom: 1px solid #e5e7eb;
}
.window:last-child {
border-bottom: 0;
}
.muted {
color: #6b7280;
font-size: 14px;
}
.danger-zone {
border: 1px solid #fecaca;
background: #fff7f7;
}
.grant-box {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
@media (max-width: 700px) {
table {
display: block;
overflow-x: auto;
}
.window-form {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<header>
<h1>
Parental Control
</h1>
</header>
<main>
<a
class="back"
href="/admin"
>
← Back to Users
</a>
<!-- USER HEADER -->
<div class="card">
<div class="header-row">
<div>
<div class="username">
{{ user.username }}
</div>
<div class="muted">
Linux user ID:
{{ user.id }}
</div>
</div>
{% if locked %}
<span class="status status-locked">
Locked
</span>
{% else %}
<span class="status status-unlocked">
Unlocked
</span>
{% endif %}
</div>
<div class="actions">
{% if locked %}
<form
method="post"
action="/admin/users/{{ user.id }}/unlock"
>
<button
class="primary"
type="submit"
>
Unlock
</button>
</form>
{% else %}
<form
method="post"
action="/admin/users/{{ user.id }}/lock"
>
<button
class="warning"
type="submit"
>
Lock
</button>
</form>
{% endif %}
<form
method="post"
action="/admin/users/{{ user.id }}/terminate"
>
<button
class="secondary"
type="submit"
>
Terminate Session
</button>
</form>
</div>
</div>
<!-- DAILY ALLOWANCES -->
<div class="card">
<h2>
Daily Allowance
</h2>
<p class="muted">
Maximum amount of usage allowed on each day.
</p>
<table>
<thead>
<tr>
<th>
Day
</th>
<th>
Hours
</th>
<th>
Minutes
</th>
<th>
Save
</th>
</tr>
</thead>
<tbody>
{% for weekday, name in weekdays %}
{% set total_seconds = allowances.get(
weekday,
0
) %}
{% set total_minutes = total_seconds // 60 %}
{% set hours = total_minutes // 60 %}
{% set minutes = total_minutes % 60 %}
<tr>
<td>
<strong>
{{ name }}
</strong>
</td>
<td colspan="2">
<form
class="inline-form"
method="post"
action="/admin/users/{{ user.id }}/allowance"
>
<input
type="hidden"
name="weekday"
value="{{ weekday }}"
>
<input
type="number"
name="hours"
min="0"
value="{{ hours }}"
required
>
<span>
hours
</span>
<input
type="number"
name="minutes"
min="0"
max="59"
value="{{ minutes }}"
required
>
<span>
minutes
</span>
</td>
<td>
<button
class="primary"
type="submit"
>
Save
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- ACCESS WINDOWS -->
<div class="card">
<h2>
Access Windows
</h2>
<p class="muted">
Define when this user is allowed to access
the computer. Multiple windows can be created
for the same day.
</p>
{% for weekday, name in weekdays %}
{% set day_windows = [] %}
{% for window in windows %}
{% if window.weekday == weekday %}
{% set _ = day_windows.append(window) %}
{% endif %}
{% endfor %}
<h3>
{{ name }}
</h3>
{% for window in day_windows %}
{% set start_hour =
window.start_minute // 60
%}
{% set start_min =
window.start_minute % 60
%}
{% set end_hour =
window.end_minute // 60
%}
{% set end_min =
window.end_minute % 60
%}
<div class="window">
<div>
<strong>
{{ "%02d:%02d" | format(
start_hour,
start_min
) }}
{{ "%02d:%02d" | format(
end_hour,
end_min
) }}
</strong>
</div>
<form
method="post"
action="/admin/windows/{{ window.id }}/delete"
>
<button
class="danger"
type="submit"
>
Remove
</button>
</form>
</div>
{% endfor %}
<form
class="window-form"
method="post"
action="/admin/users/{{ user.id }}/window"
style="margin-top: 12px;"
>
<input
type="hidden"
name="weekday"
value="{{ weekday }}"
>
<input
type="time"
name="start_time"
required
>
<input
type="time"
name="end_time"
required
>
<button
class="primary"
type="submit"
>
Add Window
</button>
</form>
{% endfor %}
</div>
<!-- TEMPORARY GRANT -->
<div class="card">
<h2>
Give Temporary Time
</h2>
<p class="muted">
Add extra time that can be used outside the
normal allowance.
</p>
<form
class="grant-box"
method="post"
action="/admin/users/{{ user.id }}/grant"
>
<input
type="number"
name="hours"
min="0"
value="0"
required
>
<span>
hours
</span>
<input
type="number"
name="minutes"
min="0"
max="59"
value="5"
required
>
<span>
minutes
</span>
<button
class="primary"
type="submit"
>
Give Time
</button>
</form>
</div>
<!-- GRANT HISTORY -->
<div class="card">
<h2>
Temporary Grants
</h2>
{% if grants %}
<table>
<thead>
<tr>
<th>
Time
</th>
<th>
Created
</th>
<th>
Status
</th>
</tr>
</thead>
<tbody>
{% for grant in grants %}
<tr>
<td>
{% set grant_minutes =
grant.seconds // 60
%}
{% set grant_hours =
grant_minutes // 60
%}
{% set grant_remaining =
grant_minutes % 60
%}
{{ grant_hours }}h
{{ grant_remaining }}m
</td>
<td>
{{ grant.created_at }}
</td>
<td>
{% if grant.consumed %}
Used
{% else %}
Available
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">
No temporary grants yet.
</p>
{% endif %}
</div>
<!-- DANGER ZONE -->
<div class="card danger-zone">
<h2>
Danger Zone
</h2>
<p class="muted">
Removing this user deletes their parental
control configuration from this application.
It does not delete the Linux account.
</p>
<form
method="post"
action="/admin/users/{{ user.id }}/delete"
onsubmit="
return confirm(
'Remove this user from parental control?'
);
"
>
<button
class="danger"
type="submit"
>
Remove from Parental Control
</button>
</form>
</div>
</main>
</body>
</html>