Sync from main server - 2026-05-05 00:20:15

This commit is contained in:
root
2026-05-05 00:20:15 +02:00
parent a8db6b5fa2
commit 09bbe0403c
5 changed files with 560 additions and 336 deletions

View File

@@ -13,7 +13,7 @@ from config import (
)
def _run(cmd, timeout=20):
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()
@@ -22,7 +22,6 @@ def _run(cmd, timeout=20):
def _human_bytes(n):
"""Human-readable byte size for audit UI."""
n = int(n)
if n < 1024:
return f'{n} B'
@@ -35,16 +34,18 @@ def _human_bytes(n):
return f'{n / (1024 ** 4):.2f} TB'
def _ssh_main(remote_cmd, timeout=20):
def _ssh_main(remote_cmd, timeout=30):
if RUNNING_ON_MAIN_SERVER:
return _run(remote_cmd, timeout=timeout)
else:
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} '{remote_cmd}'", timeout=timeout)
return _run(f"{ssh} '{escaped}'", timeout=timeout)
# ────────────────────────────────────────────────────────────────
@@ -71,6 +72,7 @@ def get_vm_backups():
cmd = (
f"ssh -i {VM_KEY} -p {VM_PORT} "
f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 "
f"-o BatchMode=yes "
f"{VM_USER}@{VM_HOST} "
f"'ls -t /backups/main-server/myapps-backup-*.tar.gz 2>/dev/null | head -20'"
)
@@ -96,22 +98,6 @@ def get_vm_backups():
# ────────────────────────────────────────────────────────────────
def audit_backup(backup_file, source='local'):
"""
Perform a health and integrity audit on a backup archive.
Checks:
1. File exists
2. File size sanity
3. SHA256 checksum (if .sha256 sidecar exists)
4. tar archive integrity (gzip test only — portable, no conflicting flags)
5. Expected internal structure
6. Path traversal / suspicious paths
7. Suspicious script files at unexpected locations (scripts only, not binaries)
8. Volume count
Returns:
{ ok, score, checks, summary }
"""
checks = []
def add(name, status, detail='', more=None):
@@ -120,13 +106,11 @@ def audit_backup(backup_file, source='local'):
entry['more'] = more
checks.append(entry)
# ── Resolve archive path ─────────────────────────────────────────────────
if source == 'local':
archive_path = f"/root/backups/{backup_file}"
else:
archive_path = f"/backups/main-server/{backup_file}"
# On VM auditing a "local" (main server) backup → pull to /tmp first
if not RUNNING_ON_MAIN_SERVER and source == 'local':
tmp_path = f"/tmp/audit_{backup_file}"
if not os.path.exists(tmp_path):
@@ -151,7 +135,6 @@ def audit_backup(backup_file, source='local'):
}
archive_path = tmp_path
# ── CHECK 1: File exists ─────────────────────────────────────────────────
if not os.path.exists(archive_path):
add('File Exists', 'fail', f'Not found: {archive_path}')
return {
@@ -165,7 +148,6 @@ def audit_backup(backup_file, source='local'):
}
add('File Exists', 'pass', archive_path)
# ── CHECK 2: File size ───────────────────────────────────────────────────
size_bytes = os.path.getsize(archive_path)
size_mb = size_bytes / (1024 * 1024)
size_human = _human_bytes(size_bytes)
@@ -183,7 +165,6 @@ def audit_backup(backup_file, source='local'):
add('File Size', 'pass',
f'{size_human} — within expected range', more=size_more)
# ── CHECK 3: SHA256 checksum ─────────────────────────────────────────────
sha_file = archive_path + '.sha256'
if os.path.exists(sha_file):
try:
@@ -201,8 +182,6 @@ def audit_backup(backup_file, source='local'):
add('Checksum (SHA256)', 'warn',
'No .sha256 sidecar found — run a new backup to get checksums')
# ── CHECK 4: Archive integrity ───────────────────────────────────────────
# Use gzip --test which works everywhere without conflicting tar flags
try:
result = subprocess.run(
['gzip', '--test', archive_path],
@@ -216,11 +195,9 @@ def audit_backup(backup_file, source='local'):
add('Archive Integrity', 'fail',
f'gzip test failed: {(result.stderr or result.stdout)[:200]}')
except FileNotFoundError:
# gzip not available — try python gzip
try:
import gzip
with gzip.open(archive_path, 'rb') as f:
# Read just the first few MB to check header validity
f.read(1024 * 1024)
add('Archive Integrity', 'pass', 'gzip header valid')
except Exception as e:
@@ -230,7 +207,6 @@ def audit_backup(backup_file, source='local'):
except Exception as e:
add('Archive Integrity', 'warn', f'Could not test: {e}')
# ── Read archive member list (used by checks 5, 6, 7, 8) ─────────────────
members = []
try:
with tarfile.open(archive_path, 'r:gz') as tf:
@@ -238,7 +214,6 @@ def audit_backup(backup_file, source='local'):
except Exception:
pass
# ── CHECK 5: Internal structure ──────────────────────────────────────────
if members:
has_volumes = any('volumes/' in m for m in members)
has_info = any('backup-info.txt' in m for m in members)
@@ -259,7 +234,6 @@ def audit_backup(backup_file, source='local'):
else:
add('Internal Structure', 'warn', 'Could not inspect archive members')
# ── CHECK 6: Path traversal / suspicious paths ────────────────────────────
SUSPICIOUS = [
(r'\.\./', 'path traversal (..)'),
(r'^/', 'absolute path in archive'),
@@ -285,10 +259,6 @@ def audit_backup(backup_file, source='local'):
'(e.g. .ssh, /etc/shadow).',
])
# ── CHECK 7: Suspicious scripts (smart — scripts only, not data files) ────
# Only flag actual text script files (.sh .py .pl .rb) with execute bits
# placed outside compose-files/ and outside known vendor directories.
# .bin, .so, .exe data files are intentionally excluded (too many false positives)
SCRIPT_EXTENSIONS = ('.sh', '.py', '.pl', '.rb', '.bash', '.zsh')
SAFE_PREFIXES = (
'compose-files/',
@@ -303,10 +273,8 @@ def audit_backup(backup_file, source='local'):
if not member.isfile():
continue
name = member.name
# Skip files in known-safe directories
if any(name.startswith(p) or f'/{p}' in name for p in SAFE_PREFIXES):
continue
# Only flag actual script extensions with execute bits
name_lower = name.lower()
has_script_ext = any(name_lower.endswith(ext) for ext in SCRIPT_EXTENSIONS)
has_exec_bit = bool(member.mode & 0o111)
@@ -321,7 +289,6 @@ def audit_backup(backup_file, source='local'):
else:
add('Executable Scripts', 'pass', 'No unexpected executable scripts found')
# ── CHECK 8: Volume count ────────────────────────────────────────────────
vol_archives = [m for m in members if 'volumes/' in m and m.endswith('.tar.gz')]
v = len(vol_archives)
if v == 0:
@@ -331,7 +298,6 @@ def audit_backup(backup_file, source='local'):
else:
add('Volume Count', 'pass', f'{v} volume archives present')
# ── Score ─────────────────────────────────────────────────────────────────
weights = {'pass': 10, 'warn': 5, 'fail': 0}
total = len(checks) * 10
earned = sum(weights.get(c['status'], 0) for c in checks)
@@ -427,6 +393,7 @@ def delete_backup(backup_file, source='local'):
cmd = (
f"ssh -i {VM_KEY} -p {VM_PORT} "
f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 "
f"-o BatchMode=yes "
f"{VM_USER}@{VM_HOST} "
f"'rm -f /backups/main-server/{backup_file} "
f"/backups/main-server/{backup_file}.sha256'"
@@ -621,19 +588,59 @@ def get_all_stats():
# ────────────────────────────────────────────────────────────────
# SYSTEM INFO
# SYSTEM INFO — single batched SSH call
# ────────────────────────────────────────────────────────────────
def get_system_info():
cpu_out, _ = _ssh_main("top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4}'")
mem_out, _ = _ssh_main("free -m | awk 'NR==2{printf \"%s/%sMB\", $3, $2}'")
mem_pct, _ = _ssh_main("free | awk 'NR==2{printf \"%.0f\", $3/$2*100}'")
disk_out, _ = _ssh_main("df -h / | awk 'NR==2{printf \"%s/%s\", $3, $2}'")
disk_pct, _ = _ssh_main("df / | awk 'NR==2{print $5}' | tr -d '%'")
load_out, _ = _ssh_main("cat /proc/loadavg | awk '{print $1, $2, $3}'")
uptime, _ = _ssh_main("uptime -p")
docker_v, _ = _ssh_main("docker --version | cut -d' ' -f3 | tr -d ','")
hostname, _ = _run("hostname -f 2>/dev/null || hostname")
"""
Collect all system metrics in a SINGLE SSH call instead of 8 separate ones.
Emits a pipe-delimited line: cpu|mem|mem_pct|disk|disk_pct|load|uptime|docker_v|hostname
"""
batch_cmd = (
"printf '%s|%s|%s|%s|%s|%s|%s|%s|%s\\n' "
"\"$(top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4}')\" "
"\"$(free -m | awk 'NR==2{printf \"%s/%sMB\", $3, $2}')\" "
"\"$(free | awk 'NR==2{printf \"%.0f\", $3/$2*100}')\" "
"\"$(df -h / | awk 'NR==2{printf \"%s/%s\", $3, $2}')\" "
"\"$(df / | awk 'NR==2{print $5}' | tr -d '%')\" "
"\"$(cat /proc/loadavg | awk '{print $1, $2, $3}')\" "
"\"$(uptime -p)\" "
"\"$(docker --version 2>/dev/null | cut -d' ' -f3 | tr -d ',')\" "
"\"$(hostname -f 2>/dev/null || hostname)\""
)
stdout, stderr = _ssh_main(batch_cmd, timeout=20)
# Parse the pipe-delimited result
if stdout and '|' in stdout:
# Use the last line in case there's extra output
for line in reversed(stdout.splitlines()):
line = line.strip()
if '|' in line:
parts = line.split('|')
if len(parts) >= 9:
return {
'cpu_pct': parts[0] or '0',
'memory': parts[1] or 'N/A',
'mem_pct': parts[2] or '0',
'disk': parts[3] or 'N/A',
'disk_pct': parts[4] or '0',
'load': parts[5] or 'N/A',
'uptime': parts[6] or 'N/A',
'docker_v': parts[7] or 'N/A',
'hostname': parts[8] or 'main server',
}
# Fallback: individual calls if batch failed
cpu_out, _ = _ssh_main("top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4}'")
mem_out, _ = _ssh_main("free -m | awk 'NR==2{printf \"%s/%sMB\", $3, $2}'")
mem_pct, _ = _ssh_main("free | awk 'NR==2{printf \"%.0f\", $3/$2*100}'")
disk_out, _ = _ssh_main("df -h / | awk 'NR==2{printf \"%s/%s\", $3, $2}'")
disk_pct, _ = _ssh_main("df / | awk 'NR==2{print $5}' | tr -d '%'")
load_out, _ = _ssh_main("cat /proc/loadavg | awk '{print $1, $2, $3}'")
uptime, _ = _ssh_main("uptime -p")
docker_v, _ = _ssh_main("docker --version | cut -d' ' -f3 | tr -d ','")
hostname, _ = _run("hostname -f 2>/dev/null || hostname")
return {
'cpu_pct': cpu_out or '0',
'memory': mem_out or 'N/A',
@@ -644,4 +651,4 @@ def get_system_info():
'uptime': uptime or 'N/A',
'docker_v': docker_v or 'N/A',
'hostname': hostname or 'main server',
}
}

View File

@@ -1,12 +1,17 @@
# modules/users.py
import os
import subprocess
import pwd
import re
import tempfile
import stat
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=20):
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()
@@ -16,80 +21,179 @@ def _run(cmd, timeout=20):
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) with info."""
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:
for pw in pwd.getpwall():
if pw.pw_uid < 1000 or pw.pw_name == 'nobody':
continue
uid = pw.pw_uid
name = pw.pw_name
home = pw.pw_dir
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)
return r.stdout.strip()
except:
return ''
sock = f"/run/user/{uid}/docker.sock"
has_docker = os.path.exists(sock)
# Get all non-system users
passwd_lines = run("getent passwd | awk -F: '$3 >= 1000 && $1 != \"nobody\" {print}'")
users = []
disk_out, _ = _run(f"du -sh {home} 2>/dev/null | cut -f1")
disk_used = disk_out.strip() or 'N/A'
for line in passwd_lines.splitlines():
parts = line.split(':')
if len(parts) < 6:
continue
name = parts[0]
uid = int(parts[2])
home = parts[5]
linger_out, _ = _run(f"loginctl show-user {name} --property=Linger 2>/dev/null")
linger = 'yes' in linger_out.lower()
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'
container_count = 0
if has_docker:
cnt_out, _ = _run(
f"DOCKER_HOST=unix://{sock} docker ps -aq 2>/dev/null | wc -l"
)
try:
container_count = int(cnt_out.strip())
except ValueError:
container_count = 0
linger_out = run(f"loginctl show-user {name} --property=Linger 2>/dev/null")
linger = 'yes' in linger_out.lower()
# Check if user has a dedicated virtual disk mounted
disk_img = f"/home/{name}.img"
has_vdisk = os.path.exists(disk_img)
vdisk_mount = None
vdisk_size = None
if has_vdisk:
# Check if it's mounted somewhere
mnt_out, _ = _run(f"findmnt -S {disk_img} -o TARGET --noheadings 2>/dev/null")
vdisk_mount = mnt_out.strip() or None
# Get size of the image
size_out, _ = _run(f"du -sh {disk_img} 2>/dev/null | cut -f1")
vdisk_size = size_out.strip() or None
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
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,
})
except Exception as e:
print(f"[users] Error listing users: {e}")
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."""
"""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:
pw = pwd.getpwnam(username)
except KeyError:
uid = int(uid_out.strip())
except ValueError:
return []
uid = pw.pw_uid
sock = f"/run/user/{uid}/docker.sock"
if not os.path.exists(sock):
sock_check, _ = _ssh_main(f"test -S {sock} && echo yes || echo no")
if sock_check.strip() != 'yes':
return []
out, _ = _run(
out, _ = _ssh_main(
f"DOCKER_HOST=unix://{sock} "
f"docker ps -a --format '{{{{.Names}}}}|{{{{.Status}}}}|{{{{.Image}}}}|{{{{.Ports}}}}' 2>/dev/null"
)
@@ -110,7 +214,7 @@ def get_user_containers(username):
def get_all_users_containers():
"""Get containers from ALL users' rootless docker instances."""
"""Get containers from ALL users' rootless docker instances on main server."""
all_containers = []
for user in get_all_users():
if user['has_docker']:
@@ -118,92 +222,159 @@ def get_all_users_containers():
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 and optionally set up rootless docker + virtual disk.
disk_quota_mb: if set, creates a loop-device virtual disk of that size (MB)
and mounts it as the user's home directory.
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 = []
# Validate
if not re.match(r'^[a-z][a-z0-9_-]{1,30}$', username):
return False, "Invalid username. Use lowercase letters, numbers, _ or -"
# Check existence
try:
pwd.getpwnam(username)
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"
except KeyError:
pass
# Create user
out, err = _run(f"useradd -m -s /bin/bash {username}")
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")
# Set password
if password:
out, err = _run(f"echo '{username}:{password}' | chpasswd")
out, err = _ssh_main(f"echo '{username}:{password}' | chpasswd")
if err:
logs.append(f"⚠️ Password set failed: {err}")
else:
logs.append("✅ Password set")
# Install prerequisites
_run("apt-get install -y uidmap dbus-user-session curl 2>/dev/null", timeout=60)
_ssh_main(
"apt-get install -y uidmap dbus-user-session curl 2>/dev/null",
timeout=60
)
logs.append("✅ Prerequisites ready")
# Enable linger
_run(f"loginctl enable-linger {username}")
_ssh_main(f"loginctl enable-linger {username}")
logs.append("✅ Linger enabled")
# Virtual disk (loop device) instead of quota
if disk_quota_mb:
success, msg = _setup_virtual_disk(username, disk_quota_mb, logs)
if not success:
logs.append(f"⚠️ Virtual disk setup failed: {msg}")
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")
# Setup rootless docker
if setup_docker:
success, msg = _setup_rootless_docker_via_script(username, logs)
if not success:
logs.append(f"⚠️ Docker setup incomplete: {msg}")
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):
"""
Create a loop-device virtual disk for a user and mount it as their home.
Steps:
1. Create a blank image file at /home/<username>.img
2. Format it as ext4
3. Copy existing home contents into it
4. Mount it over /home/<username>
5. Add to /etc/fstab for persistence across reboots
6. Fix ownership
Returns (success: bool, message: str)
"""
try:
pw = pwd.getpwnam(username)
except KeyError as e:
return False, str(e)
home = pw.pw_dir # e.g. /home/secuser4
home = pw.pw_dir
img_path = f"/home/{username}.img"
logs.append(f"📦 Creating {disk_mb} MB virtual disk at {img_path} ...")
# ── Step 1: Create the blank image ──────────────────────────────────────
# Use fallocate (fast, instant) with dd fallback
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 (this may take a moment)...")
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
@@ -212,33 +383,24 @@ def _setup_virtual_disk(username, disk_mb, logs):
return False, f"Failed to create image: {err}"
logs.append(f" ✅ Image file created ({disk_mb} MB)")
# ── Step 2: Format as ext4 ───────────────────────────────────────────────
out, err = _run(f"mkfs.ext4 -F {img_path}", timeout=60)
if err and 'mke2fs' not in err and 'Discarding device blocks' not in err:
# mkfs.ext4 writes info to stderr even on success; only fail on real errors
if 'error' in err.lower() or 'failed' in err.lower():
return False, f"mkfs.ext4 failed: {err}"
if err and 'error' in err.lower() and 'failed' in err.lower():
return False, f"mkfs.ext4 failed: {err}"
logs.append(" ✅ Formatted as ext4")
# ── Step 3: Back up current home contents ────────────────────────────────
tmp_backup = f"/tmp/{username}_home_backup"
_run(f"cp -a {home}/. {tmp_backup}/ 2>/dev/null")
# ── Step 4: Mount the image over the user's home ─────────────────────────
out, err = _run(f"mount -o loop {img_path} {home}")
if err:
return False, f"mount failed: {err}"
logs.append(f" ✅ Mounted at {home}")
# ── Step 5: Restore home contents into the new disk ──────────────────────
_run(f"cp -a {tmp_backup}/. {home}/ 2>/dev/null")
_run(f"rm -rf {tmp_backup}")
# ── Step 6: Fix ownership ─────────────────────────────────────────────────
_run(f"chown -R {username}:{username} {home}")
logs.append(" ✅ Ownership set")
# ── Step 7: Add to /etc/fstab for persistence ────────────────────────────
fstab_line = f"{img_path} {home} ext4 loop,defaults 0 0\n"
try:
with open('/etc/fstab', 'r') as f:
@@ -246,18 +408,15 @@ def _setup_virtual_disk(username, disk_mb, logs):
if img_path not in fstab:
with open('/etc/fstab', 'a') as f:
f.write(fstab_line)
logs.append(" ✅ Added to /etc/fstab (persistent across reboots)")
else:
logs.append(" Already in /etc/fstab")
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 dedicated to {username}")
logs.append(f"✅ Virtual disk ready: {disk_mb} MB for {username}")
return True, "ok"
def _remove_virtual_disk(username, logs):
"""Unmount and remove the virtual disk image for a user."""
try:
pw = pwd.getpwnam(username)
home = pw.pw_dir
@@ -265,17 +424,13 @@ def _remove_virtual_disk(username, logs):
return
img_path = f"/home/{username}.img"
# Unmount
_run(f"umount {home} 2>/dev/null")
logs.append(f" ↳ Unmounted {home}")
# Remove image
if os.path.exists(img_path):
os.remove(img_path)
logs.append(f" ↳ Removed {img_path}")
# Remove from fstab
try:
with open('/etc/fstab', 'r') as f:
lines = f.readlines()
@@ -289,22 +444,16 @@ def _remove_virtual_disk(username, logs):
def _setup_rootless_docker_via_script(username, logs):
"""
Setup rootless Docker for a user by running the official installer.
This must be done AS the user in a proper login shell.
"""
try:
pw = pwd.getpwnam(username)
uid = pw.pw_uid
uid = pw.pw_uid
home = pw.pw_dir
except KeyError as e:
return False, str(e)
# First, ensure the sysctl setting is applied (critical!)
_run("sysctl -w kernel.apparmor_restrict_unprivileged_userns=0")
_run("echo 'kernel.apparmor_restrict_unprivileged_userns=0' >> /etc/sysctl.conf")
# Ensure XDG_RUNTIME_DIR exists with correct permissions
runtime_dir = f"/run/user/{uid}"
os.makedirs(runtime_dir, exist_ok=True)
_run(f"chown {username}:{username} {runtime_dir}")
@@ -312,28 +461,20 @@ def _setup_rootless_docker_via_script(username, logs):
logs.append(f"📝 Installing rootless Docker for {username}...")
# Create a simple installation script that runs as the user
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
# Install rootless Docker
curl -fsSL https://get.docker.com/rootless | sh
# Add environment variables to .bashrc
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
# Create systemd service
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
@@ -341,30 +482,20 @@ 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
# Enable and start the service
systemctl --user daemon-reload
systemctl --user enable docker.service
systemctl --user start docker.service
# Wait for socket
sleep 5
'"""
try:
# Run the installation as the user
result = subprocess.run(
['su', '-', username, '-c', install_cmd],
capture_output=True,
text=True,
timeout=300
capture_output=True, text=True, timeout=300
)
# Log output
for line in result.stdout.split('\n'):
if line.strip():
logs.append(line.strip())
@@ -373,7 +504,6 @@ sleep 5
if line.strip():
logs.append(f" stderr: {line.strip()}")
# Check if socket exists
sock = f"/run/user/{uid}/docker.sock"
if os.path.exists(sock):
logs.append(f"✅ Rootless Docker ready for {username}")
@@ -387,69 +517,4 @@ sleep 5
return False, "timeout"
except Exception as e:
logs.append(f"⚠️ Setup failed: {e}")
return False, str(e)
def delete_user(username, remove_home=False):
"""Remove a user. Returns (success, message)."""
logs = []
try:
pwd.getpwnam(username)
except KeyError:
return False, f"User '{username}' does not exist"
# Stop their docker service first
try:
pw = pwd.getpwnam(username)
_run(
f"XDG_RUNTIME_DIR=/run/user/{pw.pw_uid} "
f"su --login {username} --command 'systemctl --user stop docker-rootless.service 2>/dev/null' "
f"2>/dev/null"
)
except Exception:
pass
_run(f"loginctl disable-linger {username} 2>/dev/null")
# Clean up virtual disk BEFORE userdel (userdel might complain if home is busy)
_remove_virtual_disk(username, logs)
flag = '-r' if remove_home else ''
out, err = _run(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)
def get_user_disk_usage(username):
try:
pw = pwd.getpwnam(username)
except KeyError:
return {}
total_out, _ = _run(f"du -sh {pw.pw_dir} 2>/dev/null | cut -f1")
# Also check if there's a virtual disk and its capacity
img_path = f"/home/{username}.img"
vdisk_info = {}
if os.path.exists(img_path):
# Get mounted filesystem usage
df_out, _ = _run(f"df -h {pw.pw_dir} 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': pw.pw_dir,
'total': total_out or 'N/A',
'vdisk': vdisk_info,
}
return False, str(e)