520 lines
18 KiB
Python
520 lines
18 KiB
Python
# modules/users.py
|
|
import os
|
|
import subprocess
|
|
import pwd
|
|
import re
|
|
import json
|
|
|
|
from config import (
|
|
RUNNING_ON_MAIN_SERVER,
|
|
MAIN_SERVER_IP, MAIN_SERVER_USER, MAIN_SERVER_KEY, MAIN_SERVER_PORT,
|
|
)
|
|
|
|
|
|
def _run(cmd, timeout=30):
|
|
try:
|
|
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
|
return r.stdout.strip(), r.stderr.strip()
|
|
except subprocess.TimeoutExpired:
|
|
return '', 'timeout'
|
|
except Exception as e:
|
|
return '', str(e)
|
|
|
|
|
|
def _ssh_main(remote_cmd, timeout=30):
|
|
"""
|
|
Run a command ON THE MAIN SERVER.
|
|
- If already on main server → run locally.
|
|
- If on VM → SSH to main server first.
|
|
"""
|
|
if RUNNING_ON_MAIN_SERVER:
|
|
return _run(remote_cmd, timeout=timeout)
|
|
else:
|
|
# Escape single quotes in remote_cmd for safe shell wrapping
|
|
escaped = remote_cmd.replace("'", "'\\''")
|
|
ssh = (
|
|
f"ssh -i {MAIN_SERVER_KEY} -p {MAIN_SERVER_PORT} "
|
|
f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 "
|
|
f"-o BatchMode=yes "
|
|
f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}"
|
|
)
|
|
return _run(f"{ssh} '{escaped}'", timeout=timeout)
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# USER LISTING — single batched SSH call to main server
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
def get_all_users():
|
|
"""
|
|
Return list of non-system users (uid >= 1000) from the MAIN SERVER.
|
|
Uses a SINGLE SSH call with a bash one-liner that collects all data at once,
|
|
instead of making 7+ SSH calls per user.
|
|
"""
|
|
|
|
# This script runs on the main server and emits one JSON line per user
|
|
batch_script = r"""
|
|
python3 - <<'PYEOF'
|
|
import subprocess, json, os, pwd
|
|
|
|
def run(cmd):
|
|
try:
|
|
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)
|
|
return r.stdout.strip()
|
|
except:
|
|
return ''
|
|
|
|
# Get all non-system users
|
|
passwd_lines = run("getent passwd | awk -F: '$3 >= 1000 && $1 != \"nobody\" {print}'")
|
|
users = []
|
|
|
|
for line in passwd_lines.splitlines():
|
|
parts = line.split(':')
|
|
if len(parts) < 6:
|
|
continue
|
|
name = parts[0]
|
|
uid = int(parts[2])
|
|
home = parts[5]
|
|
|
|
sock = f"/run/user/{uid}/docker.sock"
|
|
has_docker = os.path.exists(sock)
|
|
disk_used = run(f"du -sh {home} 2>/dev/null | cut -f1") or 'N/A'
|
|
|
|
linger_out = run(f"loginctl show-user {name} --property=Linger 2>/dev/null")
|
|
linger = 'yes' in linger_out.lower()
|
|
|
|
container_count = 0
|
|
if has_docker:
|
|
cnt = run(f"DOCKER_HOST=unix://{sock} docker ps -aq 2>/dev/null | wc -l")
|
|
try:
|
|
container_count = int(cnt)
|
|
except:
|
|
pass
|
|
|
|
img_path = f"/home/{name}.img"
|
|
has_vdisk = os.path.exists(img_path)
|
|
vdisk_mount = None
|
|
vdisk_size = None
|
|
if has_vdisk:
|
|
mnt = run(f"findmnt -S {img_path} -o TARGET --noheadings 2>/dev/null")
|
|
vdisk_mount = mnt or None
|
|
sz = run(f"du -sh {img_path} 2>/dev/null | cut -f1")
|
|
vdisk_size = sz or None
|
|
|
|
users.append({
|
|
'name': name,
|
|
'uid': uid,
|
|
'home': home,
|
|
'has_docker': has_docker,
|
|
'docker_socket': sock if has_docker else None,
|
|
'disk_used': disk_used,
|
|
'linger': linger,
|
|
'container_count': container_count,
|
|
'has_vdisk': has_vdisk,
|
|
'vdisk_mount': vdisk_mount,
|
|
'vdisk_size': vdisk_size,
|
|
})
|
|
|
|
print(json.dumps(users))
|
|
PYEOF
|
|
"""
|
|
|
|
stdout, stderr = _ssh_main(batch_script, timeout=60)
|
|
|
|
if not stdout:
|
|
# Fallback: try a simpler approach if python3 one-liner fails
|
|
return _get_all_users_fallback()
|
|
|
|
# Find the JSON line (last non-empty line that starts with '[')
|
|
for line in reversed(stdout.splitlines()):
|
|
line = line.strip()
|
|
if line.startswith('['):
|
|
try:
|
|
return json.loads(line)
|
|
except json.JSONDecodeError:
|
|
break
|
|
|
|
return _get_all_users_fallback()
|
|
|
|
|
|
def _get_all_users_fallback():
|
|
"""
|
|
Simpler fallback: just get user names/UIDs, skip slow per-user checks.
|
|
Returns basic user list without docker/disk details.
|
|
"""
|
|
users = []
|
|
stdout, _ = _ssh_main(
|
|
"getent passwd | awk -F: '$3 >= 1000 && $1 != \"nobody\" {print $1\"|\"$3\"|\"$6}'"
|
|
)
|
|
if not stdout:
|
|
return users
|
|
|
|
for line in stdout.splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
parts = line.split('|')
|
|
if len(parts) < 3:
|
|
continue
|
|
try:
|
|
uid = int(parts[1])
|
|
except ValueError:
|
|
continue
|
|
users.append({
|
|
'name': parts[0],
|
|
'uid': uid,
|
|
'home': parts[2],
|
|
'has_docker': False,
|
|
'docker_socket': None,
|
|
'disk_used': 'N/A',
|
|
'linger': False,
|
|
'container_count': 0,
|
|
'has_vdisk': False,
|
|
'vdisk_mount': None,
|
|
'vdisk_size': None,
|
|
})
|
|
return users
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# USER CONTAINERS — always from main server
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
def get_user_containers(username):
|
|
"""Get containers running under a specific user's rootless docker on the main server."""
|
|
uid_out, _ = _ssh_main(f"id -u {username} 2>/dev/null")
|
|
try:
|
|
uid = int(uid_out.strip())
|
|
except ValueError:
|
|
return []
|
|
|
|
sock = f"/run/user/{uid}/docker.sock"
|
|
sock_check, _ = _ssh_main(f"test -S {sock} && echo yes || echo no")
|
|
if sock_check.strip() != 'yes':
|
|
return []
|
|
|
|
out, _ = _ssh_main(
|
|
f"DOCKER_HOST=unix://{sock} "
|
|
f"docker ps -a --format '{{{{.Names}}}}|{{{{.Status}}}}|{{{{.Image}}}}|{{{{.Ports}}}}' 2>/dev/null"
|
|
)
|
|
containers = []
|
|
if out:
|
|
for line in out.split('\n'):
|
|
if '|' not in line:
|
|
continue
|
|
parts = line.split('|')
|
|
containers.append({
|
|
'name': parts[0] if len(parts) > 0 else '',
|
|
'status': parts[1] if len(parts) > 1 else '',
|
|
'image': parts[2] if len(parts) > 2 else '',
|
|
'ports': parts[3] if len(parts) > 3 else '',
|
|
'owner': username,
|
|
})
|
|
return containers
|
|
|
|
|
|
def get_all_users_containers():
|
|
"""Get containers from ALL users' rootless docker instances on main server."""
|
|
all_containers = []
|
|
for user in get_all_users():
|
|
if user['has_docker']:
|
|
all_containers.extend(get_user_containers(user['name']))
|
|
return all_containers
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# USER DISK USAGE — always from main server
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
def get_user_disk_usage(username):
|
|
home_out, _ = _ssh_main(
|
|
f"getent passwd {username} | cut -d: -f6"
|
|
)
|
|
home = home_out.strip()
|
|
if not home:
|
|
return {}
|
|
|
|
total_out, _ = _ssh_main(f"du -sh {home} 2>/dev/null | cut -f1")
|
|
|
|
img_path = f"/home/{username}.img"
|
|
vdisk_out, _ = _ssh_main(f"test -f {img_path} && echo yes || echo no")
|
|
vdisk_info = {}
|
|
|
|
if vdisk_out.strip() == 'yes':
|
|
df_out, _ = _ssh_main(f"df -h {home} 2>/dev/null | tail -1")
|
|
if df_out:
|
|
parts = df_out.split()
|
|
if len(parts) >= 4:
|
|
vdisk_info = {
|
|
'size': parts[1],
|
|
'used': parts[2],
|
|
'available': parts[3],
|
|
'use_pct': parts[4] if len(parts) > 4 else '?',
|
|
}
|
|
|
|
return {
|
|
'home': home,
|
|
'total': total_out or 'N/A',
|
|
'vdisk': vdisk_info,
|
|
}
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# CREATE / DELETE USER — only works on main server
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
def create_user(username, password=None, setup_docker=True, disk_quota_mb=None):
|
|
"""
|
|
Create a new system user on the MAIN SERVER.
|
|
If called from VM, all commands SSH to main server.
|
|
Returns (success: bool, log_text: str)
|
|
"""
|
|
logs = []
|
|
|
|
if not re.match(r'^[a-z][a-z0-9_-]{1,30}$', username):
|
|
return False, "Invalid username. Use lowercase letters, numbers, _ or -"
|
|
|
|
check_out, _ = _ssh_main(f"id {username} 2>/dev/null && echo exists || echo notfound")
|
|
if 'exists' in check_out:
|
|
return False, f"User '{username}' already exists"
|
|
|
|
out, err = _ssh_main(f"useradd -m -s /bin/bash {username}")
|
|
if err and 'already exists' not in err:
|
|
return False, f"useradd failed: {err}"
|
|
logs.append(f"✅ User {username} created")
|
|
|
|
if password:
|
|
out, err = _ssh_main(f"echo '{username}:{password}' | chpasswd")
|
|
if err:
|
|
logs.append(f"⚠️ Password set failed: {err}")
|
|
else:
|
|
logs.append("✅ Password set")
|
|
|
|
_ssh_main(
|
|
"apt-get install -y uidmap dbus-user-session curl 2>/dev/null",
|
|
timeout=60
|
|
)
|
|
logs.append("✅ Prerequisites ready")
|
|
|
|
_ssh_main(f"loginctl enable-linger {username}")
|
|
logs.append("✅ Linger enabled")
|
|
|
|
if disk_quota_mb:
|
|
if RUNNING_ON_MAIN_SERVER:
|
|
success, msg = _setup_virtual_disk(username, disk_quota_mb, logs)
|
|
if not success:
|
|
logs.append(f"⚠️ Virtual disk setup failed: {msg}")
|
|
else:
|
|
logs.append("⚠️ Virtual disk setup must be run directly on main server")
|
|
|
|
if setup_docker:
|
|
if RUNNING_ON_MAIN_SERVER:
|
|
success, msg = _setup_rootless_docker_via_script(username, logs)
|
|
if not success:
|
|
logs.append(f"⚠️ Docker setup incomplete: {msg}")
|
|
else:
|
|
logs.append("⚠️ Rootless Docker setup must be run directly on main server")
|
|
|
|
return True, '\n'.join(logs)
|
|
|
|
|
|
def delete_user(username, remove_home=False):
|
|
"""Remove a user from the MAIN SERVER. Returns (success, message)."""
|
|
logs = []
|
|
|
|
check_out, _ = _ssh_main(f"id {username} 2>/dev/null && echo exists || echo notfound")
|
|
if 'notfound' in check_out:
|
|
return False, f"User '{username}' does not exist"
|
|
|
|
uid_out, _ = _ssh_main(f"id -u {username} 2>/dev/null")
|
|
try:
|
|
uid = int(uid_out.strip())
|
|
_ssh_main(
|
|
f"XDG_RUNTIME_DIR=/run/user/{uid} "
|
|
f"su --login {username} --command "
|
|
f"'systemctl --user stop docker-rootless.service 2>/dev/null' 2>/dev/null"
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
_ssh_main(f"loginctl disable-linger {username} 2>/dev/null")
|
|
|
|
if RUNNING_ON_MAIN_SERVER:
|
|
_remove_virtual_disk(username, logs)
|
|
else:
|
|
img_path = f"/home/{username}.img"
|
|
home = f"/home/{username}"
|
|
_ssh_main(f"umount {home} 2>/dev/null || true")
|
|
_ssh_main(f"rm -f {img_path} 2>/dev/null || true")
|
|
|
|
flag = '-r' if remove_home else ''
|
|
out, err = _ssh_main(f"userdel {flag} {username}")
|
|
if err and 'does not exist' not in err and 'mail spool' not in err:
|
|
return False, f"userdel error: {err}"
|
|
|
|
msg = f"✅ User {username} deleted" + (" (home removed)" if remove_home else "")
|
|
logs.append(msg)
|
|
return True, '\n'.join(logs)
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# LOCAL-ONLY HELPERS (only called when RUNNING_ON_MAIN_SERVER)
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
def _setup_virtual_disk(username, disk_mb, logs):
|
|
try:
|
|
pw = pwd.getpwnam(username)
|
|
except KeyError as e:
|
|
return False, str(e)
|
|
|
|
home = pw.pw_dir
|
|
img_path = f"/home/{username}.img"
|
|
|
|
logs.append(f"📦 Creating {disk_mb} MB virtual disk at {img_path} ...")
|
|
|
|
out, err = _run(f"fallocate -l {disk_mb}M {img_path}", timeout=60)
|
|
if err and 'fallocate' in err:
|
|
logs.append(" ↳ fallocate not available, using dd...")
|
|
out, err = _run(
|
|
f"dd if=/dev/zero of={img_path} bs=1M count={disk_mb} status=none",
|
|
timeout=600
|
|
)
|
|
if err:
|
|
return False, f"Failed to create image: {err}"
|
|
logs.append(f" ✅ Image file created ({disk_mb} MB)")
|
|
|
|
out, err = _run(f"mkfs.ext4 -F {img_path}", timeout=60)
|
|
if err and 'error' in err.lower() and 'failed' in err.lower():
|
|
return False, f"mkfs.ext4 failed: {err}"
|
|
logs.append(" ✅ Formatted as ext4")
|
|
|
|
tmp_backup = f"/tmp/{username}_home_backup"
|
|
_run(f"cp -a {home}/. {tmp_backup}/ 2>/dev/null")
|
|
|
|
out, err = _run(f"mount -o loop {img_path} {home}")
|
|
if err:
|
|
return False, f"mount failed: {err}"
|
|
logs.append(f" ✅ Mounted at {home}")
|
|
|
|
_run(f"cp -a {tmp_backup}/. {home}/ 2>/dev/null")
|
|
_run(f"rm -rf {tmp_backup}")
|
|
_run(f"chown -R {username}:{username} {home}")
|
|
logs.append(" ✅ Ownership set")
|
|
|
|
fstab_line = f"{img_path} {home} ext4 loop,defaults 0 0\n"
|
|
try:
|
|
with open('/etc/fstab', 'r') as f:
|
|
fstab = f.read()
|
|
if img_path not in fstab:
|
|
with open('/etc/fstab', 'a') as f:
|
|
f.write(fstab_line)
|
|
logs.append(" ✅ Added to /etc/fstab")
|
|
except Exception as e:
|
|
logs.append(f" ⚠️ Could not update /etc/fstab: {e}")
|
|
|
|
logs.append(f"✅ Virtual disk ready: {disk_mb} MB for {username}")
|
|
return True, "ok"
|
|
|
|
|
|
def _remove_virtual_disk(username, logs):
|
|
try:
|
|
pw = pwd.getpwnam(username)
|
|
home = pw.pw_dir
|
|
except KeyError:
|
|
return
|
|
|
|
img_path = f"/home/{username}.img"
|
|
_run(f"umount {home} 2>/dev/null")
|
|
logs.append(f" ↳ Unmounted {home}")
|
|
|
|
if os.path.exists(img_path):
|
|
os.remove(img_path)
|
|
logs.append(f" ↳ Removed {img_path}")
|
|
|
|
try:
|
|
with open('/etc/fstab', 'r') as f:
|
|
lines = f.readlines()
|
|
with open('/etc/fstab', 'w') as f:
|
|
for line in lines:
|
|
if img_path not in line:
|
|
f.write(line)
|
|
logs.append(" ↳ Removed from /etc/fstab")
|
|
except Exception as e:
|
|
logs.append(f" ⚠️ fstab cleanup error: {e}")
|
|
|
|
|
|
def _setup_rootless_docker_via_script(username, logs):
|
|
try:
|
|
pw = pwd.getpwnam(username)
|
|
uid = pw.pw_uid
|
|
home = pw.pw_dir
|
|
except KeyError as e:
|
|
return False, str(e)
|
|
|
|
_run("sysctl -w kernel.apparmor_restrict_unprivileged_userns=0")
|
|
_run("echo 'kernel.apparmor_restrict_unprivileged_userns=0' >> /etc/sysctl.conf")
|
|
|
|
runtime_dir = f"/run/user/{uid}"
|
|
os.makedirs(runtime_dir, exist_ok=True)
|
|
_run(f"chown {username}:{username} {runtime_dir}")
|
|
_run(f"chmod 700 {runtime_dir}")
|
|
|
|
logs.append(f"📝 Installing rootless Docker for {username}...")
|
|
|
|
install_cmd = f"""bash -c '
|
|
export XDG_RUNTIME_DIR=/run/user/{uid}
|
|
export PATH=$HOME/bin:$PATH
|
|
mkdir -p $XDG_RUNTIME_DIR
|
|
chmod 700 $XDG_RUNTIME_DIR
|
|
curl -fsSL https://get.docker.com/rootless | sh
|
|
echo "export PATH=$HOME/bin:\\$PATH" >> ~/.bashrc
|
|
echo "export DOCKER_HOST=unix:///run/user/{uid}/docker.sock" >> ~/.bashrc
|
|
echo "export XDG_RUNTIME_DIR=/run/user/{uid}" >> ~/.bashrc
|
|
mkdir -p ~/.config/systemd/user
|
|
cat > ~/.config/systemd/user/docker.service << EOF
|
|
[Unit]
|
|
Description=Docker Rootless Daemon
|
|
After=network.target
|
|
[Service]
|
|
Type=simple
|
|
ExecStart=$HOME/bin/dockerd-rootless.sh
|
|
Restart=always
|
|
RestartSec=10
|
|
Environment=PATH=$HOME/bin:/usr/local/bin:/usr/bin:/bin
|
|
Environment=XDG_RUNTIME_DIR=/run/user/{uid}
|
|
[Install]
|
|
WantedBy=default.target
|
|
EOF
|
|
systemctl --user daemon-reload
|
|
systemctl --user enable docker.service
|
|
systemctl --user start docker.service
|
|
sleep 5
|
|
'"""
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
['su', '-', username, '-c', install_cmd],
|
|
capture_output=True, text=True, timeout=300
|
|
)
|
|
for line in result.stdout.split('\n'):
|
|
if line.strip():
|
|
logs.append(line.strip())
|
|
if result.stderr:
|
|
for line in result.stderr.split('\n')[-5:]:
|
|
if line.strip():
|
|
logs.append(f" stderr: {line.strip()}")
|
|
|
|
sock = f"/run/user/{uid}/docker.sock"
|
|
if os.path.exists(sock):
|
|
logs.append(f"✅ Rootless Docker ready for {username}")
|
|
return True, "ok"
|
|
else:
|
|
logs.append(f"⚠️ Socket not found at {sock}")
|
|
return True, "socket_pending"
|
|
|
|
except subprocess.TimeoutExpired:
|
|
logs.append("⚠️ Setup timed out after 300s")
|
|
return False, "timeout"
|
|
except Exception as e:
|
|
logs.append(f"⚠️ Setup failed: {e}")
|
|
return False, str(e) |