Files
Proxmox-AIS-Server/provisioner/builtin_modules/ssh.sh
T
BartelLuis 06c3474636
CI / javascript-check (push) Successful in 51s
CI / container-policy (push) Successful in 2s
CI / container-verify (push) Canceled after 0s
CI / container-publish (push) Canceled after 0s
CI / python-tests (push) Canceled after 6m59s
feat: add Proxmox provisioning service with CI and deployment tooling
2026-09-14 20:09:12 +02:00

97 lines
5.1 KiB
Bash

#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import base64, json, os, pathlib, pwd, re, subprocess, sys, tempfile
def validate_public_key(key):
if not isinstance(key, str) or '\n' in key or '\r' in key or len(key) > 16384:
raise SystemExit('Invalid SSH public key')
parts = key.split()
if len(parts) < 2 or parts[0] not in ('ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521'):
raise SystemExit('Unsupported SSH public key format')
try:
blob = base64.b64decode(parts[1], validate=True)
size = int.from_bytes(blob[:4], 'big')
if blob[4:4 + size].decode() != parts[0] or len(blob) <= 4 + size:
raise ValueError()
except (ValueError, UnicodeError):
raise SystemExit('Invalid SSH public key encoding')
# sshd -t does not parse authorized_keys. Validate the complete public key.
fd, key_file = tempfile.mkstemp(prefix='pve-public-key-')
try:
with os.fdopen(fd, 'w') as stream:
stream.write(key + '\n')
parsed = subprocess.run(['ssh-keygen', '-l', '-f', key_file], capture_output=True, text=True, timeout=15)
if parsed.returncode != 0:
raise SystemExit('OpenSSH rejected the configured public key')
finally:
os.unlink(key_file)
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
users = p.get('users', [])
if not isinstance(users, list) or len(users) > 50:
raise SystemExit('Invalid SSH users')
changes = []
for entry in users:
name = entry.get('name', '')
if not isinstance(name, str) or not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', name):
raise SystemExit('Invalid SSH account name')
try:
user = pwd.getpwnam(name)
except KeyError:
raise SystemExit('SSH module requires an existing user account')
keys = entry.get('authorized_keys', [])
if not isinstance(keys, list) or not keys or len(keys) > 100:
raise SystemExit('At least one authorized key per configured user is required')
for key in keys:
validate_public_key(key)
home = pathlib.Path(user.pw_dir)
if not home.is_dir() or home.stat().st_uid not in (0, user.pw_uid) or home.stat().st_mode & 0o022:
raise SystemExit('Account home must have safe ownership and must not be writable by group or others')
if user.pw_shell in ('/usr/sbin/nologin', '/sbin/nologin', '/bin/false'):
raise SystemExit('SSH account requires an interactive login shell')
effective = subprocess.run(['/usr/sbin/sshd', '-T', '-C', f'user={name},host=localhost,addr=127.0.0.1'], check=True, capture_output=True, text=True, timeout=30)
settings = dict(line.split(None, 1) for line in effective.stdout.splitlines() if ' ' in line)
if settings.get('pubkeyauthentication') != 'yes' or (name == 'root' and settings.get('permitrootlogin') not in ('yes', 'prohibit-password', 'without-password')):
raise SystemExit('Effective SSH policy does not permit public-key login for this account')
key_paths = settings.get('authorizedkeysfile', '').split()
accepted = {'.ssh/authorized_keys', '%h/.ssh/authorized_keys', str(home / '.ssh/authorized_keys')}
if not accepted.intersection(key_paths):
raise SystemExit('Effective SSH policy does not use the managed authorized_keys file')
directory = pathlib.Path(user.pw_dir) / '.ssh'
target = directory / 'authorized_keys'
if directory.is_symlink() or target.is_symlink():
raise SystemExit('Refusing symlinked SSH paths')
existing = target.read_text() if target.exists() else ''
missing = [key for key in keys if key not in existing.splitlines()]
correct_permissions = directory.exists() and target.exists() and directory.stat().st_mode & 0o777 == 0o700 and target.stat().st_mode & 0o777 == 0o600 and target.stat().st_uid == user.pw_uid and directory.stat().st_uid == user.pw_uid
if missing or not correct_permissions:
changes.append((user, directory, target, existing, missing))
if mode == 'check':
sys.exit(1 if changes else 0)
if mode == 'apply':
for user, directory, target, existing, missing in changes:
directory.mkdir(mode=0o700, exist_ok=True)
os.chmod(directory, 0o700)
os.chown(directory, user.pw_uid, user.pw_gid)
content = existing.rstrip('\n') + ('\n' if existing else '') + '\n'.join(missing) + ('\n' if missing else '')
fd, name = tempfile.mkstemp(prefix='.authorized-', dir=directory)
try:
os.fchmod(fd, 0o600)
os.fchown(fd, user.pw_uid, user.pw_gid)
with os.fdopen(fd, 'w') as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, target)
finally:
if os.path.exists(name):
os.unlink(name)
subprocess.run(['/usr/sbin/sshd', '-t'], check=True, timeout=30)
subprocess.run(['systemctl', 'is-active', '--quiet', 'ssh.service'], check=True, timeout=30)
if mode == 'verify' and changes:
raise SystemExit(1)
print(json.dumps({'passed': True, 'accounts': len(users), 'sshd': 'validated'}))
PY