feat: add Proxmox provisioning service with CI and deployment tooling
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# Mitgelieferte Modulentwürfe
|
||||
|
||||
Alle acht Module werden als **Entwurf ohne Hardware-Testnachweis und ohne freigegebene Zielbuilds** angeboten. Vor Veröffentlichung müssen Quelltext, konkrete Parameter und Verhalten auf einem passenden Testhost geprüft werden. Die Syntaxprüfung ersetzt diesen Nachweis nicht.
|
||||
|
||||
| Modul | Parameter und Umfang |
|
||||
| --- | --- |
|
||||
| Voraussetzungen | `allowed_versions`: exakte Versionsstrings aus `pveversion`; `dns_names`: aufzulösende Namen; `minimum_free_mb`: freier Platz auf `/`. Eine leere Versionsliste lässt die zusätzliche Versionsprüfung aus; die Buildfreigabe im Dienst bleibt erforderlich. |
|
||||
| Paketquellen | `url`, `suite`, `components`, `keyring` sind zwingend. Verwaltet genau `/etc/apt/sources.list.d/pve-provisioner.sources` mit HTTPS und vorhandenem APT-Schlüsselbund. Andere Quellen, insbesondere Subscription-Konfigurationen, werden nicht automatisch entfernt. |
|
||||
| Basispakete | `packages`: Debian-Paketnamen ohne Shell-Ausdrücke oder APT-Optionen. Installiert fehlende Pakete, wartet auf die Paketmanagersperre und prüft anschließend den Installationsstatus. Führt kein allgemeines Systemupgrade aus. |
|
||||
| SSH-Zugang | `users`: Liste aus `name` und `authorized_keys`. Benutzer müssen bereits existieren und eine Login-Shell sowie ein sicher berechtigtes Home-Verzeichnis haben. OpenSSH validiert vollständige Schlüssel vor jeder Änderung. Schlüssel werden ergänzt und Dateirechte, effektive lokale SSH-Schlüsselrichtlinie, `sshd -t` sowie der aktive Dienst geprüft. Verbindung und gegebenenfalls abweichende `Match`-Regeln aus dem realen Managementnetz sind separat zu testen. |
|
||||
| Zeitsynchronisation | `servers`: explizite NTP-Hostnamen oder IP-Adressen. `chrony` muss zuvor installiert sein und `sourcedir /etc/chrony/sources.d` verwenden. Verwaltet eine eigene Quelldatei und wartet begrenzt auf Synchronisation. |
|
||||
| Monitoring | `enabled`: standardmäßig `false`. Bei Aktivierung muss `prometheus-node-exporter` bereits installiert sein. Aktiviert den Dienst und prüft dessen lokalen Metrics-Endpunkt. Netzwerkzugriff auf den Exporter muss im Standortnetz passend geregelt sein. |
|
||||
| Zusätzlicher Storage | `id`, `path`, `content`: registriert ein bereits existierendes Verzeichnis unter `/mnt/` oder `/srv/` als PVE-Verzeichnisstorage. Kein Formatieren, kein Mounten, keine Änderung widersprüchlicher vorhandener Storage-Konfiguration. |
|
||||
| Abschlussprüfung | `allowed_versions`, `dns_names`, `storage_ids`, `require_time_sync`: prüft PVE-Dienste, Version, DNS, Zeit und angegebenen aktiven Storage. |
|
||||
|
||||
Modulabhängigkeiten beziehen sich im Verwaltungsmodell auf **Modulnamen**. Beim Freigeben werden sie auf die konkreten Schritt-IDs des unveränderlichen Laufmanifests aufgelöst. Profilparameter werden gegen das jeweilige JSON-Schema geprüft. Beispielsweise muss das Paketprofil `chrony` enthalten, wenn der Zeitschritt auf einem Host ohne Chrony eingeplant wird.
|
||||
|
||||
## Modulvertrag
|
||||
|
||||
Der Runner startet `bash modul.sh check|apply|verify parameter.json`. `check` liefert `0`, wenn der Sollzustand erreicht ist, `1`, wenn eine Änderung nötig ist, und einen anderen Rückgabecode für einen Prüffehler. Ein Schritt gilt erst nach erfolgreichem `verify` als erfolgreich. `apply` darf mit `194` einen geplanten Neustart anfordern; der Runner schreibt zuerst seinen Checkpoint und kontrolliert das Neustartbudget. Ein Modul darf den Neustart nicht selbst auslösen.
|
||||
|
||||
Die Parameterdatei enthält die freigegebenen Parameter sowie ein Objekt `secrets` mit ausschließlich den Geheimnissen des aktuellen Schritts. Sie wird mit Modus `0600` angelegt und nach dem Schritt entfernt. Module sollen keine Geheimnisse ausgeben; zusätzlich redigiert der Runner bekannte Geheimniswerte vor der dauerhaften Logablage. Logausgabe ist pro Phase und in der lokalen Warteschlange begrenzt.
|
||||
|
||||
Das Schritt-Timeout gilt gemeinsam für `check`, `apply` und `verify`. Nach Unterbrechungen werden `check` und `verify` erneut ausgeführt; ein nicht bestätigter Zustand darf nur bei `retry_safe=true` erneut angewendet werden. Ein permanenter Fehler wartet auf eine explizite Wiederaufnahme im Webtool. Die standardmäßige Wartefrist beträgt 24 Stunden, die maximale automatische Wiederherstellung bei Netzausfall 30 Minuten.
|
||||
|
||||
Chrony-Kommandos orientieren sich an der offiziellen Dokumentation zu [chronyc](https://chrony-project.org/doc/4.7/chronyc.html) und [sourcedir](https://chrony-project.org/doc/4.7/chrony.conf.html). Die tatsächliche Paketversion und Distribution bleiben Bestandteil des Zielhost-Tests.
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Conservative module drafts. Publication always requires target-host evidence."""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def schema(properties, required=()):
|
||||
return {"type": "object", "additionalProperties": False, "properties": properties, "required": list(required)}
|
||||
|
||||
|
||||
STRING_LIST = {"type": "array", "items": {"type": "string"}, "maxItems": 100}
|
||||
DEFINITIONS = [
|
||||
("prerequisites", "Voraussetzungen", "PVE-Version, DNS, Uhrzeit und freien Speicher prüfen.",
|
||||
schema({"allowed_versions": STRING_LIST, "dns_names": STRING_LIST,
|
||||
"minimum_free_mb": {"type": "integer", "minimum": 512, "maximum": 1048576}}),
|
||||
{"allowed_versions": [], "dns_names": [], "minimum_free_mb": 2048}, [], 120, True),
|
||||
("repositories", "Paketquellen", "Eine signierte, explizit freigegebene HTTPS-Paketquelle verwalten.",
|
||||
schema({"url": {"type": "string"}, "suite": {"type": "string"}, "components": STRING_LIST,
|
||||
"keyring": {"type": "string"}}, ("url", "suite", "components", "keyring")),
|
||||
{}, ["prerequisites"], 600, True),
|
||||
("packages", "Basispakete", "Explizit genannte Pakete installieren; keine globale Aktualisierung.",
|
||||
schema({"packages": STRING_LIST}), {"packages": []}, ["prerequisites"], 1800, True),
|
||||
("ssh", "SSH-Zugang", "Freigegebene Schlüssel vorhandenen Benutzern hinzufügen; sshd validieren.",
|
||||
schema({"users": {"type": "array", "maxItems": 50, "items": schema({
|
||||
"name": {"type": "string", "pattern": "^[a-z_][a-z0-9_-]{0,31}$"},
|
||||
"authorized_keys": STRING_LIST}, ("name", "authorized_keys"))}}),
|
||||
{"users": []}, ["prerequisites"], 120, True),
|
||||
("time", "Zeitsynchronisation", "Chrony mit expliziten Zeitservern konfigurieren und Synchronisation prüfen.",
|
||||
schema({"servers": STRING_LIST}, ("servers",)), {"servers": []}, ["packages"], 600, True),
|
||||
("monitoring", "Monitoring", "Optional den Debian prometheus-node-exporter aktivieren.",
|
||||
schema({"enabled": {"type": "boolean"}}), {"enabled": False}, ["packages"], 600, False),
|
||||
("storage", "Zusätzlicher Storage", "Vorhandenes Verzeichnis ohne Formatierung als PVE-Storage anbinden.",
|
||||
schema({"id": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,31}$"},
|
||||
"path": {"type": "string"}, "content": STRING_LIST}, ("id", "path", "content")),
|
||||
{}, ["prerequisites"], 120, True),
|
||||
("final-verification", "Abschlussprüfung", "PVE-Dienste, Versionsstand, DNS, Zeit und aktiven Storage prüfen.",
|
||||
schema({"allowed_versions": STRING_LIST, "dns_names": STRING_LIST,
|
||||
"storage_ids": STRING_LIST, "require_time_sync": {"type": "boolean"}}),
|
||||
{"allowed_versions": [], "dns_names": [], "storage_ids": [], "require_time_sync": True},
|
||||
["prerequisites"], 180, True),
|
||||
]
|
||||
|
||||
|
||||
def catalog():
|
||||
result = []
|
||||
root = Path(__file__).parent
|
||||
names = {definition[0]: definition[1] for definition in DEFINITIONS}
|
||||
for module_id, name, description, parameters_schema, defaults, dependencies, timeout, required in DEFINITIONS:
|
||||
result.append({"id": module_id, "name": name, "description": description, "version": "1.0.0",
|
||||
"source": (root / f"{module_id}.sh").read_text(encoding="utf-8"),
|
||||
"parameters_schema": parameters_schema, "default_parameters": defaults,
|
||||
"dependencies": [names[dependency] for dependency in dependencies], "timeout_seconds": timeout,
|
||||
"retry_safe": True, "required": required, "status": "draft",
|
||||
"test_evidence": "", "target_builds": []})
|
||||
return result
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
|
||||
exec python3 - "$1" "$2" <<'PY'
|
||||
import json, pathlib, re, socket, subprocess, sys
|
||||
mode, params_file = sys.argv[1:]
|
||||
p = json.loads(pathlib.Path(params_file).read_text())
|
||||
try:
|
||||
version = subprocess.run(['pveversion'], check=True, capture_output=True, text=True, timeout=30).stdout
|
||||
match = re.search(r'pve-manager/([^/\s]+)', version)
|
||||
if not match or (p.get('allowed_versions') and match[1] not in p['allowed_versions']):
|
||||
raise ValueError('PVE version verification failed')
|
||||
for service in ('pve-cluster.service', 'pvedaemon.service', 'pveproxy.service', 'pvestatd.service'):
|
||||
subprocess.run(['systemctl', 'is-active', '--quiet', service], check=True, timeout=30)
|
||||
for name in p.get('dns_names', []):
|
||||
socket.getaddrinfo(name, 443)
|
||||
if p.get('require_time_sync', True):
|
||||
sync = subprocess.run(['timedatectl', 'show', '-p', 'NTPSynchronized', '--value'], check=True, capture_output=True, text=True, timeout=30)
|
||||
if sync.stdout.strip() != 'yes':
|
||||
raise ValueError('System clock is not synchronized')
|
||||
for storage_id in p.get('storage_ids', []):
|
||||
if not isinstance(storage_id, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]{0,31}', storage_id):
|
||||
raise ValueError('Invalid storage identifier')
|
||||
result = subprocess.run(['pvesm', 'status', '--storage', storage_id], check=True, capture_output=True, text=True, timeout=30)
|
||||
if not any(re.match(r'^' + re.escape(storage_id) + r'\s+\S+\s+active\s', line) for line in result.stdout.splitlines()):
|
||||
raise ValueError('Required storage is not active')
|
||||
print(json.dumps({'passed': True, 'pve_version': match[1], 'services': 'active', 'clock': 'checked', 'storage': 'checked'}))
|
||||
except (ValueError, OSError, subprocess.SubprocessError) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
sys.exit(2)
|
||||
PY
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
|
||||
exec python3 - "$1" "$2" <<'PY'
|
||||
import json, pathlib, subprocess, sys, urllib.request
|
||||
mode, params_file = sys.argv[1:]
|
||||
p = json.loads(pathlib.Path(params_file).read_text())
|
||||
if not isinstance(p.get('enabled', False), bool):
|
||||
raise SystemExit('enabled must be a boolean')
|
||||
if not p.get('enabled', False):
|
||||
print('{"passed":true,"enabled":false}')
|
||||
raise SystemExit(0)
|
||||
service = 'prometheus-node-exporter.service'
|
||||
active = subprocess.run(['systemctl', 'is-active', '--quiet', service], timeout=30).returncode == 0
|
||||
if mode == 'check':
|
||||
sys.exit(0 if active else 1)
|
||||
if mode == 'apply':
|
||||
package = subprocess.run(['dpkg-query', '-W', '-f=${Status}', 'prometheus-node-exporter'], capture_output=True, text=True, timeout=30)
|
||||
if package.returncode != 0 or package.stdout != 'install ok installed':
|
||||
raise SystemExit('Install prometheus-node-exporter with the package module first')
|
||||
subprocess.run(['systemctl', 'enable', '--now', service], check=True, timeout=60)
|
||||
subprocess.run(['systemctl', 'is-active', '--quiet', service], check=True, timeout=30)
|
||||
with urllib.request.urlopen('http://127.0.0.1:9100/metrics', timeout=10) as response:
|
||||
body = response.read(2 * 1024 * 1024)
|
||||
if b'node_exporter_build_info' not in body:
|
||||
raise SystemExit('Node exporter metrics validation failed')
|
||||
print('{"passed":true,"metrics":"available"}')
|
||||
PY
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
|
||||
exec python3 - "$1" "$2" <<'PY'
|
||||
import json, pathlib, re, subprocess, sys
|
||||
mode, params_file = sys.argv[1:]
|
||||
p = json.loads(pathlib.Path(params_file).read_text())
|
||||
packages = p.get('packages', [])
|
||||
if not isinstance(packages, list) or len(packages) > 100 or any(not isinstance(x, str) or not re.fullmatch(r'[a-z0-9][a-z0-9+.-]{0,100}', x) for x in packages):
|
||||
raise SystemExit('Invalid package list; names only, no options or shell expressions')
|
||||
def installed(name):
|
||||
result = subprocess.run(['dpkg-query', '-W', '-f=${Status}', name], capture_output=True, text=True, timeout=30)
|
||||
return result.returncode == 0 and result.stdout == 'install ok installed'
|
||||
missing = [name for name in packages if not installed(name)]
|
||||
if mode == 'apply' and missing:
|
||||
subprocess.run(['apt-get', '-o', 'DPkg::Lock::Timeout=180', 'update'], check=True, timeout=600)
|
||||
subprocess.run(['apt-get', '-o', 'DPkg::Lock::Timeout=180', 'install', '-y', '--no-install-recommends', '--', *missing], check=True, timeout=1200)
|
||||
missing = [name for name in packages if not installed(name)]
|
||||
print(json.dumps({'passed': not missing, 'missing': missing}))
|
||||
sys.exit(1 if missing else 0)
|
||||
PY
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
|
||||
exec python3 - "$1" "$2" <<'PY'
|
||||
import json, pathlib, re, shutil, socket, subprocess, sys, time
|
||||
mode, params_file = sys.argv[1:]
|
||||
p = json.loads(pathlib.Path(params_file).read_text())
|
||||
try:
|
||||
result = subprocess.run(['pveversion'], check=True, capture_output=True, text=True, timeout=20)
|
||||
match = re.search(r'pve-manager/([^/\s]+)', result.stdout)
|
||||
if not match:
|
||||
raise ValueError('Target does not report a Proxmox VE manager version')
|
||||
if p.get('allowed_versions') and match[1] not in p['allowed_versions']:
|
||||
raise ValueError('PVE version is outside the approved module target versions')
|
||||
minimum = p.get('minimum_free_mb', 2048)
|
||||
if not isinstance(minimum, int) or not 512 <= minimum <= 1048576:
|
||||
raise ValueError('minimum_free_mb is invalid')
|
||||
if shutil.disk_usage('/').free < minimum * 1024 * 1024:
|
||||
raise ValueError('Insufficient free root filesystem space')
|
||||
if time.time() < 1704067200:
|
||||
raise ValueError('System clock is not plausible')
|
||||
for name in p.get('dns_names', []):
|
||||
if not isinstance(name, str) or not name or len(name) > 253:
|
||||
raise ValueError('Invalid DNS target')
|
||||
socket.getaddrinfo(name, 443)
|
||||
print(json.dumps({'passed': True, 'pve_version': match[1], 'dns': 'resolved', 'free_space': 'sufficient'}))
|
||||
except (ValueError, OSError, subprocess.SubprocessError) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
sys.exit(2)
|
||||
PY
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
|
||||
exec python3 - "$1" "$2" <<'PY'
|
||||
import json, os, pathlib, re, subprocess, sys, tempfile, urllib.parse
|
||||
def has_repository_indexes(policy, url, suite, components):
|
||||
indexes = [line.split() for line in policy.splitlines()]
|
||||
return all(any(any(field.rstrip('/') == url.rstrip('/') for field in fields) and f'{suite}/{component}' in fields for fields in indexes) for component in components)
|
||||
|
||||
mode, params_file = sys.argv[1:]
|
||||
p = json.loads(pathlib.Path(params_file).read_text())
|
||||
url, suite, components, keyring = (p.get(k) for k in ('url', 'suite', 'components', 'keyring'))
|
||||
if not isinstance(url, str) or any(c.isspace() for c in url):
|
||||
raise SystemExit('An explicit HTTPS repository URL is required')
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise SystemExit('Repository URL must use HTTPS without credentials or query parameters')
|
||||
if not isinstance(suite, str) or not re.fullmatch(r'[a-z][a-z0-9-]{0,40}', suite):
|
||||
raise SystemExit('Invalid repository suite')
|
||||
if not isinstance(components, list) or not components or any(not isinstance(c, str) or not re.fullmatch(r'[a-z][a-z0-9/-]{0,50}', c) for c in components):
|
||||
raise SystemExit('Invalid repository components')
|
||||
if not isinstance(keyring, str) or not re.fullmatch(r'/(usr/share|etc/apt)/keyrings/[A-Za-z0-9_.-]+\.(gpg|asc)', keyring) or not pathlib.Path(keyring).is_file():
|
||||
raise SystemExit('An existing administrator-provisioned APT keyring is required')
|
||||
expected = f'Types: deb\nURIs: {url}\nSuites: {suite}\nComponents: {" ".join(components)}\nSigned-By: {keyring}\n'
|
||||
target = pathlib.Path('/etc/apt/sources.list.d/pve-provisioner.sources')
|
||||
matches = target.is_file() and target.read_text() == expected
|
||||
if mode == 'check':
|
||||
sys.exit(0 if matches else 1)
|
||||
if mode == 'apply' and not matches:
|
||||
fd, name = tempfile.mkstemp(prefix='.pve-provisioner-', dir=target.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o644)
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
stream.write(expected)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(name, target)
|
||||
finally:
|
||||
if os.path.exists(name):
|
||||
os.unlink(name)
|
||||
if mode == 'apply':
|
||||
subprocess.run(['apt-get', '-o', 'DPkg::Lock::Timeout=180', 'update'], check=True, timeout=500)
|
||||
policy = subprocess.run(['apt-cache', 'policy'], check=True, capture_output=True, text=True, timeout=30).stdout
|
||||
indexed = has_repository_indexes(policy, url, suite, components)
|
||||
passed = target.is_file() and target.read_text() == expected and indexed
|
||||
print(json.dumps({'passed': passed, 'repository': url, 'suite': suite}))
|
||||
sys.exit(0 if passed else 1)
|
||||
PY
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
|
||||
exec python3 - "$1" "$2" <<'PY'
|
||||
import json, pathlib, re, subprocess, sys
|
||||
mode, params_file = sys.argv[1:]
|
||||
p = json.loads(pathlib.Path(params_file).read_text())
|
||||
storage_id, location, content = (p.get(k) for k in ('id', 'path', 'content'))
|
||||
if not isinstance(storage_id, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]{0,31}', storage_id):
|
||||
raise SystemExit('Invalid storage identifier')
|
||||
if not isinstance(location, str) or any(c.isspace() for c in location) or not location.startswith(('/mnt/', '/srv/')):
|
||||
raise SystemExit('Storage must be an existing absolute directory under /mnt or /srv')
|
||||
directory = pathlib.Path(location)
|
||||
if not directory.is_dir() or directory.is_symlink() or str(directory.resolve()) != location.rstrip('/'):
|
||||
raise SystemExit('Storage directory must already exist without symbolic links or traversal')
|
||||
if not isinstance(content, list) or not content or not set(content).issubset({'images', 'rootdir', 'vztmpl', 'iso', 'backup', 'snippets'}):
|
||||
raise SystemExit('Invalid storage content types')
|
||||
def configuration():
|
||||
result = subprocess.run(['pvesh', 'get', '/storage', '--output-format', 'json'], check=True, capture_output=True, text=True, timeout=30)
|
||||
if not any(entry.get('storage') == storage_id for entry in json.loads(result.stdout)):
|
||||
return None
|
||||
detail = subprocess.run(['pvesh', 'get', '/storage/' + storage_id, '--output-format', 'json'], check=True, capture_output=True, text=True, timeout=30)
|
||||
return json.loads(detail.stdout)
|
||||
existing = configuration()
|
||||
if existing and (existing.get('type') != 'dir' or existing.get('path', '').rstrip('/') != location.rstrip('/') or set(existing.get('content', '').split(',')) != set(content)):
|
||||
raise SystemExit('Existing storage has conflicting settings; automatic changes are refused')
|
||||
if mode == 'check':
|
||||
sys.exit(0 if existing else 1)
|
||||
if mode == 'apply' and not existing:
|
||||
subprocess.run(['pvesm', 'add', 'dir', storage_id, '--path', location, '--content', ','.join(content)], check=True, timeout=60)
|
||||
result = subprocess.run(['pvesm', 'status', '--storage', storage_id], check=True, capture_output=True, text=True, timeout=30)
|
||||
if not any(re.match(r'^' + re.escape(storage_id) + r'\s+dir\s+active\s', line) for line in result.stdout.splitlines()):
|
||||
raise SystemExit('Storage is not active')
|
||||
print(json.dumps({'passed': True, 'storage': storage_id, 'destructive_operations': False}))
|
||||
PY
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
|
||||
exec python3 - "$1" "$2" <<'PY'
|
||||
import ipaddress, json, os, pathlib, re, subprocess, sys, tempfile
|
||||
mode, params_file = sys.argv[1:]
|
||||
p = json.loads(pathlib.Path(params_file).read_text())
|
||||
servers = p.get('servers')
|
||||
if not isinstance(servers, list) or not 1 <= len(servers) <= 16:
|
||||
raise SystemExit('Between one and sixteen explicit NTP servers are required')
|
||||
for server in servers:
|
||||
if not isinstance(server, str) or len(server) > 253:
|
||||
raise SystemExit('Invalid NTP server')
|
||||
try:
|
||||
ipaddress.ip_address(server)
|
||||
except ValueError:
|
||||
if not re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?', server):
|
||||
raise SystemExit('NTP server must be a hostname or IP address')
|
||||
target = pathlib.Path('/etc/chrony/sources.d/pve-provisioner.sources')
|
||||
expected = ''.join(f'server {server} iburst\n' for server in servers)
|
||||
active = subprocess.run(['systemctl', 'is-active', '--quiet', 'chrony.service'], timeout=30).returncode == 0
|
||||
matches = target.is_file() and target.read_text() == expected
|
||||
if mode == 'check':
|
||||
sys.exit(0 if matches and active else 1)
|
||||
if mode == 'apply':
|
||||
# The package module owns installation; this module never swaps NTP daemons.
|
||||
config = pathlib.Path('/etc/chrony/chrony.conf')
|
||||
if not config.is_file() or not any(line.strip() == 'sourcedir /etc/chrony/sources.d' for line in config.read_text().splitlines()):
|
||||
raise SystemExit('Install chrony first and enable its standard sources.d directory')
|
||||
target.parent.mkdir(mode=0o755, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix='.pve-provisioner-', dir=target.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o644)
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
stream.write(expected)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(name, target)
|
||||
finally:
|
||||
if os.path.exists(name):
|
||||
os.unlink(name)
|
||||
subprocess.run(['systemctl', 'enable', '--now', 'chrony.service'], check=True, timeout=60)
|
||||
subprocess.run(['chronyc', 'reload', 'sources'], check=True, timeout=30)
|
||||
subprocess.run(['chronyc', 'waitsync', '30', '0.5', '0', '2'], check=True, timeout=90)
|
||||
subprocess.run(['systemctl', 'is-active', '--quiet', 'chrony.service'], check=True, timeout=30)
|
||||
if not target.is_file() or target.read_text() != expected:
|
||||
raise SystemExit(1)
|
||||
print(json.dumps({'passed': True, 'clock': 'synchronized'}))
|
||||
PY
|
||||
Reference in New Issue
Block a user