903 lines
17 KiB
Python
903 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
import subprocess
|
|
import threading
|
|
|
|
from .config import (
|
|
DAY_NAMES,
|
|
find_user,
|
|
get_day_name,
|
|
load_state,
|
|
load_users_config,
|
|
normalize_access_windows,
|
|
normalize_allowances,
|
|
parse_time,
|
|
python_weekday_to_name,
|
|
save_state,
|
|
)
|
|
from .users import (
|
|
is_locked,
|
|
lock_user,
|
|
terminate_user,
|
|
unlock_user,
|
|
)
|
|
|
|
|
|
_state_lock = threading.RLock()
|
|
|
|
|
|
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:
|
|
if parts[1] == username:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def current_time():
|
|
now = datetime.now()
|
|
|
|
weekday = python_weekday_to_name(
|
|
now.weekday()
|
|
)
|
|
|
|
minute = (
|
|
now.hour * 60
|
|
+ now.minute
|
|
)
|
|
|
|
return (
|
|
now,
|
|
weekday,
|
|
minute,
|
|
)
|
|
|
|
|
|
def _today() -> str:
|
|
return (
|
|
datetime.now()
|
|
.date()
|
|
.isoformat()
|
|
)
|
|
|
|
|
|
def _get_usage(
|
|
state: dict,
|
|
username: str,
|
|
date: str,
|
|
) -> int:
|
|
usage = state.setdefault(
|
|
"usage",
|
|
{},
|
|
)
|
|
|
|
user_usage = usage.get(
|
|
username,
|
|
{},
|
|
)
|
|
|
|
if not isinstance(
|
|
user_usage,
|
|
dict,
|
|
):
|
|
return 0
|
|
|
|
try:
|
|
return max(
|
|
0,
|
|
int(
|
|
user_usage.get(
|
|
date,
|
|
0,
|
|
)
|
|
),
|
|
)
|
|
except (
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
return 0
|
|
|
|
|
|
def _set_usage(
|
|
state: dict,
|
|
username: str,
|
|
date: str,
|
|
seconds: int,
|
|
) -> None:
|
|
usage = state.setdefault(
|
|
"usage",
|
|
{},
|
|
)
|
|
|
|
user_usage = usage.setdefault(
|
|
username,
|
|
{},
|
|
)
|
|
|
|
user_usage[date] = max(
|
|
0,
|
|
int(seconds),
|
|
)
|
|
|
|
|
|
def _get_grants(
|
|
state: dict,
|
|
) -> list[dict]:
|
|
grants = state.setdefault(
|
|
"temporary_grants",
|
|
[],
|
|
)
|
|
|
|
if not isinstance(
|
|
grants,
|
|
list,
|
|
):
|
|
grants = []
|
|
state[
|
|
"temporary_grants"
|
|
] = grants
|
|
|
|
return grants
|
|
|
|
|
|
def _grant_is_active(
|
|
grant: dict,
|
|
username: str,
|
|
now: datetime,
|
|
) -> bool:
|
|
if str(
|
|
grant.get("username", "")
|
|
) != username:
|
|
return False
|
|
|
|
try:
|
|
remaining = int(
|
|
grant.get(
|
|
"remaining_seconds",
|
|
0,
|
|
)
|
|
)
|
|
except (
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
return False
|
|
|
|
if remaining <= 0:
|
|
return False
|
|
|
|
expires_at = grant.get(
|
|
"expires_at"
|
|
)
|
|
|
|
if not expires_at:
|
|
return True
|
|
|
|
try:
|
|
expiry = datetime.fromisoformat(
|
|
str(expires_at)
|
|
)
|
|
except ValueError:
|
|
return False
|
|
|
|
return expiry > now
|
|
|
|
|
|
def get_remaining_grant_seconds(
|
|
user_id: int,
|
|
) -> int:
|
|
user = find_user(
|
|
user_id
|
|
)
|
|
|
|
if user is None:
|
|
return 0
|
|
|
|
username = str(
|
|
user.get(
|
|
"username",
|
|
"",
|
|
)
|
|
)
|
|
|
|
now = datetime.now()
|
|
|
|
with _state_lock:
|
|
state = load_state()
|
|
|
|
total = 0
|
|
|
|
for grant in _get_grants(
|
|
state
|
|
):
|
|
if _grant_is_active(
|
|
grant,
|
|
username,
|
|
now,
|
|
):
|
|
try:
|
|
total += int(
|
|
grant.get(
|
|
"remaining_seconds",
|
|
0,
|
|
)
|
|
)
|
|
except (
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
pass
|
|
|
|
return max(
|
|
0,
|
|
total,
|
|
)
|
|
|
|
|
|
def consume_grant_seconds(
|
|
user_id: int,
|
|
seconds: int,
|
|
) -> None:
|
|
if seconds <= 0:
|
|
return
|
|
|
|
user = find_user(
|
|
user_id
|
|
)
|
|
|
|
if user is None:
|
|
return
|
|
|
|
username = str(
|
|
user.get(
|
|
"username",
|
|
"",
|
|
)
|
|
)
|
|
|
|
now = datetime.now()
|
|
|
|
with _state_lock:
|
|
state = load_state()
|
|
|
|
remaining_to_consume = (
|
|
int(seconds)
|
|
)
|
|
|
|
for grant in _get_grants(
|
|
state
|
|
):
|
|
if remaining_to_consume <= 0:
|
|
break
|
|
|
|
if not _grant_is_active(
|
|
grant,
|
|
username,
|
|
now,
|
|
):
|
|
continue
|
|
|
|
try:
|
|
available = int(
|
|
grant.get(
|
|
"remaining_seconds",
|
|
0,
|
|
)
|
|
)
|
|
except (
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
continue
|
|
|
|
consumed = min(
|
|
available,
|
|
remaining_to_consume,
|
|
)
|
|
|
|
grant[
|
|
"remaining_seconds"
|
|
] = available - consumed
|
|
|
|
remaining_to_consume -= (
|
|
consumed
|
|
)
|
|
|
|
if (
|
|
grant[
|
|
"remaining_seconds"
|
|
] <= 0
|
|
):
|
|
grant[
|
|
"remaining_seconds"
|
|
] = 0
|
|
|
|
save_state(
|
|
state
|
|
)
|
|
|
|
|
|
def record_usage(
|
|
user_id: int,
|
|
seconds: int,
|
|
) -> None:
|
|
if seconds <= 0:
|
|
return
|
|
|
|
user = find_user(
|
|
user_id
|
|
)
|
|
|
|
if user is None:
|
|
return
|
|
|
|
username = str(
|
|
user.get(
|
|
"username",
|
|
"",
|
|
)
|
|
)
|
|
|
|
today = _today()
|
|
|
|
with _state_lock:
|
|
state = load_state()
|
|
|
|
current = _get_usage(
|
|
state,
|
|
username,
|
|
today,
|
|
)
|
|
|
|
_set_usage(
|
|
state,
|
|
username,
|
|
today,
|
|
current + int(seconds),
|
|
)
|
|
|
|
save_state(
|
|
state
|
|
)
|
|
|
|
|
|
def get_user_policy(
|
|
user_id: int,
|
|
weekday: str,
|
|
):
|
|
user = find_user(
|
|
user_id
|
|
)
|
|
|
|
if user is None:
|
|
return (
|
|
0,
|
|
0,
|
|
[],
|
|
0,
|
|
)
|
|
|
|
allowances = normalize_allowances(
|
|
user.get(
|
|
"daily_allowance",
|
|
{},
|
|
)
|
|
)
|
|
|
|
allowance_seconds = (
|
|
allowances.get(
|
|
weekday,
|
|
0,
|
|
)
|
|
)
|
|
|
|
today = _today()
|
|
|
|
with _state_lock:
|
|
state = load_state()
|
|
|
|
username = str(
|
|
user.get(
|
|
"username",
|
|
"",
|
|
)
|
|
)
|
|
|
|
usage_seconds = _get_usage(
|
|
state,
|
|
username,
|
|
today,
|
|
)
|
|
|
|
grants = _get_grants(
|
|
state
|
|
)
|
|
|
|
now = datetime.now()
|
|
|
|
grant_seconds = 0
|
|
|
|
for grant in grants:
|
|
if _grant_is_active(
|
|
grant,
|
|
username,
|
|
now,
|
|
):
|
|
try:
|
|
grant_seconds += int(
|
|
grant.get(
|
|
"remaining_seconds",
|
|
0,
|
|
)
|
|
)
|
|
except (
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
pass
|
|
|
|
access_windows = normalize_access_windows(
|
|
user.get(
|
|
"access_windows",
|
|
{},
|
|
)
|
|
)
|
|
|
|
windows = access_windows.get(
|
|
weekday,
|
|
[],
|
|
)
|
|
|
|
return (
|
|
allowance_seconds,
|
|
usage_seconds,
|
|
windows,
|
|
max(
|
|
0,
|
|
grant_seconds,
|
|
),
|
|
)
|
|
|
|
|
|
def is_inside_window(
|
|
windows,
|
|
minute: int,
|
|
) -> bool:
|
|
if not windows:
|
|
return True
|
|
|
|
for window in windows:
|
|
try:
|
|
start = parse_time(
|
|
window["start"]
|
|
)
|
|
end = parse_time(
|
|
window["end"]
|
|
)
|
|
except (
|
|
KeyError,
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
continue
|
|
|
|
# Same time means all day.
|
|
if start == end:
|
|
return True
|
|
|
|
# Normal same-day window.
|
|
if start < end:
|
|
if start <= minute < end:
|
|
return True
|
|
|
|
# Overnight window.
|
|
else:
|
|
if (
|
|
minute >= start
|
|
or minute < end
|
|
):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _clean_old_state() -> None:
|
|
today = _today()
|
|
|
|
with _state_lock:
|
|
state = load_state()
|
|
|
|
usage = state.get(
|
|
"usage",
|
|
{},
|
|
)
|
|
|
|
if isinstance(
|
|
usage,
|
|
dict,
|
|
):
|
|
for username in list(
|
|
usage.keys()
|
|
):
|
|
user_usage = usage[
|
|
username
|
|
]
|
|
|
|
if not isinstance(
|
|
user_usage,
|
|
dict,
|
|
):
|
|
del usage[
|
|
username
|
|
]
|
|
continue
|
|
|
|
# Keep recent history instead
|
|
# of deleting everything.
|
|
dates = sorted(
|
|
user_usage.keys()
|
|
)
|
|
|
|
if len(dates) > 31:
|
|
for old_date in dates[
|
|
:-31
|
|
]:
|
|
del user_usage[
|
|
old_date
|
|
]
|
|
|
|
grants = state.get(
|
|
"temporary_grants",
|
|
[],
|
|
)
|
|
|
|
if isinstance(
|
|
grants,
|
|
list,
|
|
):
|
|
now = datetime.now()
|
|
|
|
cleaned = []
|
|
|
|
for grant in grants:
|
|
try:
|
|
remaining = int(
|
|
grant.get(
|
|
"remaining_seconds",
|
|
0,
|
|
)
|
|
)
|
|
except (
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
continue
|
|
|
|
if remaining <= 0:
|
|
continue
|
|
|
|
expires_at = grant.get(
|
|
"expires_at"
|
|
)
|
|
|
|
if expires_at:
|
|
try:
|
|
expiry = datetime.fromisoformat(
|
|
str(expires_at)
|
|
)
|
|
except ValueError:
|
|
continue
|
|
|
|
if expiry <= now:
|
|
continue
|
|
|
|
cleaned.append(
|
|
grant
|
|
)
|
|
|
|
state[
|
|
"temporary_grants"
|
|
] = cleaned
|
|
|
|
save_state(
|
|
state
|
|
)
|
|
|
|
|
|
def evaluate_user(
|
|
user_id: int,
|
|
username: str,
|
|
):
|
|
now, weekday, minute = (
|
|
current_time()
|
|
)
|
|
|
|
user = find_user(
|
|
user_id
|
|
)
|
|
|
|
if user is None:
|
|
return {
|
|
"user_id": user_id,
|
|
"username": username,
|
|
"timestamp": now.isoformat(),
|
|
"weekday": weekday,
|
|
"minute": minute,
|
|
"inside_window": False,
|
|
"logged_in": False,
|
|
"allowance_seconds": 0,
|
|
"usage_seconds": 0,
|
|
"allowance_remaining": 0,
|
|
"grant_seconds": 0,
|
|
"total_remaining": 0,
|
|
"allowed_by_schedule": False,
|
|
"allowed_by_grant": False,
|
|
"allowed": False,
|
|
}
|
|
|
|
if str(
|
|
user.get(
|
|
"username",
|
|
"",
|
|
)
|
|
) != username:
|
|
return {
|
|
"user_id": user_id,
|
|
"username": username,
|
|
"timestamp": now.isoformat(),
|
|
"weekday": weekday,
|
|
"minute": minute,
|
|
"inside_window": False,
|
|
"logged_in": False,
|
|
"allowance_seconds": 0,
|
|
"usage_seconds": 0,
|
|
"allowance_remaining": 0,
|
|
"grant_seconds": 0,
|
|
"total_remaining": 0,
|
|
"allowed_by_schedule": False,
|
|
"allowed_by_grant": False,
|
|
"allowed": False,
|
|
}
|
|
|
|
if not bool(
|
|
user.get(
|
|
"enabled",
|
|
True,
|
|
)
|
|
):
|
|
logged_in = user_has_session(
|
|
username
|
|
)
|
|
|
|
if logged_in:
|
|
try:
|
|
terminate_user(
|
|
username
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
if not is_locked(
|
|
username
|
|
):
|
|
lock_user(
|
|
username
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"user_id": user_id,
|
|
"username": username,
|
|
"timestamp": now.isoformat(),
|
|
"weekday": weekday,
|
|
"minute": minute,
|
|
"inside_window": False,
|
|
"logged_in": logged_in,
|
|
"allowance_seconds": 0,
|
|
"usage_seconds": 0,
|
|
"allowance_remaining": 0,
|
|
"grant_seconds": 0,
|
|
"total_remaining": 0,
|
|
"allowed_by_schedule": False,
|
|
"allowed_by_grant": False,
|
|
"allowed": False,
|
|
}
|
|
|
|
(
|
|
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
|
|
)
|
|
|
|
try:
|
|
locked = is_locked(
|
|
username
|
|
)
|
|
except Exception:
|
|
locked = False
|
|
|
|
if should_allow:
|
|
if locked:
|
|
try:
|
|
unlock_user(
|
|
username
|
|
)
|
|
|
|
# Re-check the state after
|
|
# unlocking.
|
|
if not is_locked(
|
|
username
|
|
):
|
|
pass
|
|
|
|
except Exception as exc:
|
|
with _state_lock:
|
|
state = load_state()
|
|
|
|
# The enforcement loop must
|
|
# continue even if one account
|
|
# cannot be unlocked.
|
|
_ = state
|
|
_ = exc
|
|
|
|
else:
|
|
if logged_in:
|
|
try:
|
|
terminate_user(
|
|
username
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
if not locked:
|
|
try:
|
|
lock_user(
|
|
username
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
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_by_schedule": allowed_by_schedule,
|
|
"allowed_by_grant": allowed_by_grant,
|
|
"allowed": should_allow,
|
|
}
|
|
|
|
|
|
def enforce_all_users():
|
|
_clean_old_state()
|
|
|
|
config = load_users_config()
|
|
|
|
users = config.get(
|
|
"users",
|
|
[],
|
|
)
|
|
|
|
results = []
|
|
|
|
for user in users:
|
|
if not isinstance(
|
|
user,
|
|
dict,
|
|
):
|
|
continue
|
|
|
|
if not bool(
|
|
user.get(
|
|
"enabled",
|
|
True,
|
|
)
|
|
):
|
|
continue
|
|
|
|
try:
|
|
user_id = int(
|
|
user["id"]
|
|
)
|
|
|
|
username = str(
|
|
user["username"]
|
|
)
|
|
|
|
except (
|
|
KeyError,
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
continue
|
|
|
|
try:
|
|
result = evaluate_user(
|
|
user_id,
|
|
username,
|
|
)
|
|
|
|
results.append(
|
|
result
|
|
)
|
|
|
|
except Exception as exc:
|
|
results.append(
|
|
{
|
|
"user_id": user_id,
|
|
"username": username,
|
|
"error": str(exc),
|
|
}
|
|
)
|
|
|
|
return results
|