97 lines
5.1 KiB
Bash
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
|