174 lines
3.9 KiB
Python
174 lines
3.9 KiB
Python
import grp
|
|
import pwd
|
|
import subprocess
|
|
from typing import List, Dict
|
|
|
|
|
|
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 _login_def_value(name: str, default: int) -> int:
|
|
"""Read an integer value from /etc/login.defs."""
|
|
try:
|
|
with open("/etc/login.defs", "r", encoding="utf-8") as handle:
|
|
for line in handle:
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
parts = line.split()
|
|
if len(parts) >= 2 and parts[0] == name:
|
|
value = int(parts[1])
|
|
if value > 0:
|
|
return value
|
|
except (OSError, ValueError):
|
|
pass
|
|
return default
|
|
|
|
|
|
def _uid_min() -> int:
|
|
return _login_def_value("UID_MIN", 1000)
|
|
|
|
|
|
def _uid_max() -> int:
|
|
return _login_def_value("UID_MAX", 60000)
|
|
|
|
|
|
def _is_interactive_shell(shell: str) -> bool:
|
|
"""Exclude service accounts that cannot be used for interactive logins."""
|
|
shell = (shell or "").strip().lower()
|
|
if not shell:
|
|
return False
|
|
return not shell.endswith(("/nologin", "/false"))
|
|
|
|
|
|
def _supplementary_groups(username: str) -> set[str]:
|
|
groups = set()
|
|
try:
|
|
user = pwd.getpwnam(username)
|
|
except KeyError:
|
|
return groups
|
|
|
|
try:
|
|
groups.add(grp.getgrgid(user.pw_gid).gr_name)
|
|
except KeyError:
|
|
pass
|
|
|
|
for group in grp.getgrall():
|
|
if username in group.gr_mem:
|
|
groups.add(group.gr_name)
|
|
|
|
return groups
|
|
|
|
|
|
def has_root_privileges(username: str) -> bool:
|
|
"""Best-effort detection for accounts with ordinary root-style group access."""
|
|
try:
|
|
user = pwd.getpwnam(username)
|
|
except KeyError:
|
|
return False
|
|
|
|
if user.pw_uid == 0:
|
|
return True
|
|
|
|
groups = _supplementary_groups(username)
|
|
return bool(groups.intersection({"root", "wheel", "sudo"}))
|
|
|
|
|
|
def is_non_root_user(username: str) -> bool:
|
|
"""Return True for regular non-root accounts suitable for parental control."""
|
|
try:
|
|
user = pwd.getpwnam(username)
|
|
except KeyError:
|
|
return False
|
|
|
|
if user.pw_uid < _uid_min() or user.pw_uid > _uid_max():
|
|
return False
|
|
|
|
if username in {"nobody", "nfsnobody"}:
|
|
return False
|
|
|
|
if not _is_interactive_shell(user.pw_shell):
|
|
return False
|
|
|
|
return not has_root_privileges(username)
|
|
|
|
|
|
def list_available_users() -> List[Dict[str, str]]:
|
|
"""Return regular interactive users that can be selected for parental control."""
|
|
users = []
|
|
|
|
for user in pwd.getpwall():
|
|
if not is_non_root_user(user.pw_name):
|
|
continue
|
|
|
|
users.append({"username": user.pw_name})
|
|
|
|
return sorted(users, key=lambda item: item["username"].lower())
|
|
|
|
|
|
def user_in_group(username: str, group_name: str) -> bool:
|
|
try:
|
|
group = grp.getgrnam(group_name)
|
|
user = pwd.getpwnam(username)
|
|
except KeyError:
|
|
return False
|
|
|
|
return (
|
|
username in group.gr_mem
|
|
or user.pw_gid == group.gr_gid
|
|
)
|
|
|
|
|
|
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,
|
|
)
|