288 lines
14 KiB
Python
288 lines
14 KiB
Python
"""Operational commands; no administrative password is saved in clear text."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from contextlib import closing, suppress
|
|
from dataclasses import asdict
|
|
import getpass
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import sqlite3
|
|
import sys
|
|
import tempfile
|
|
from datetime import datetime, timezone
|
|
from uuid import uuid4
|
|
|
|
|
|
class ServiceLock:
|
|
"""A process-lifetime lock shared by the web service and offline operations."""
|
|
|
|
def __init__(self, data_dir: Path):
|
|
self.path = Path(data_dir) / ".service.lock"
|
|
self.handle = None
|
|
|
|
def __enter__(self):
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
self.handle = self.path.open("a+b")
|
|
try:
|
|
if self.path.stat().st_size == 0:
|
|
self.handle.write(b"0")
|
|
self.handle.flush()
|
|
self.handle.seek(0)
|
|
if os.name == "nt":
|
|
import msvcrt
|
|
|
|
msvcrt.locking(self.handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
else:
|
|
import fcntl
|
|
|
|
fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except (OSError, BlockingIOError) as exc:
|
|
self.handle.close()
|
|
self.handle = None
|
|
raise RuntimeError("Data directory is in use. Stop the service before this operation.") from exc
|
|
return self
|
|
|
|
def __exit__(self, *_):
|
|
if self.handle is not None:
|
|
if os.name == "nt":
|
|
import msvcrt
|
|
|
|
self.handle.seek(0)
|
|
msvcrt.locking(self.handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
else:
|
|
import fcntl
|
|
|
|
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
|
self.handle.close()
|
|
self.handle = None
|
|
|
|
|
|
def load_settings():
|
|
"""Read optional TOML, then environment overrides, with safe defaults."""
|
|
from provisioner.config import Settings
|
|
|
|
return Settings.from_env()
|
|
|
|
|
|
def initialize(settings, username: str | None = None) -> None:
|
|
from cryptography.fernet import Fernet
|
|
from provisioner.db import Database
|
|
from provisioner.security import Security
|
|
|
|
data_dir = Path(settings.data_dir).resolve()
|
|
key_path = Path(settings.master_key_file).resolve()
|
|
if key_path == data_dir or data_dir in key_path.parents:
|
|
raise ValueError("MASTER_KEY_FILE must be outside DATA_DIR")
|
|
with ServiceLock(data_dir):
|
|
database = Database(settings)
|
|
database.initialize()
|
|
with database.connection() as connection:
|
|
if connection.execute("SELECT 1 FROM users LIMIT 1").fetchone():
|
|
raise ValueError("Already initialized; use the administrator interface to manage users")
|
|
username = username or input("Administrator username: ").strip()
|
|
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,63}", username):
|
|
raise ValueError("Administrator username requires 2 to 64 letters, digits, dots, underscores or hyphens")
|
|
password = getpass.getpass("Administrator password (at least 12 characters): ")
|
|
if len(password) < 12:
|
|
raise ValueError("Use a password with at least 12 characters")
|
|
if password != getpass.getpass("Repeat administrator password: "):
|
|
raise ValueError("Passwords do not match")
|
|
if not key_path.exists():
|
|
key_path.parent.mkdir(parents=True, exist_ok=True)
|
|
descriptor = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
with os.fdopen(descriptor, "wb") as handle:
|
|
handle.write(Fernet.generate_key())
|
|
security = Security(settings)
|
|
with database.connection() as connection:
|
|
connection.execute(
|
|
"INSERT INTO users(id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
(str(uuid4()), username, security.hash_password(password), "admin", datetime.now(timezone.utc).isoformat()),
|
|
)
|
|
print(f"Initialized {data_dir}. Administrator: {username}")
|
|
print(f"Back up the encryption key separately: {key_path}")
|
|
|
|
|
|
def _digest(path: Path) -> str:
|
|
with path.open("rb") as handle:
|
|
return hashlib.file_digest(handle, "sha256").hexdigest()
|
|
|
|
|
|
def backup(settings, destination: Path) -> None:
|
|
"""Snapshot SQLite, then immutable referenced artifacts; never include keys."""
|
|
from provisioner.db import Database
|
|
|
|
destination = destination.resolve()
|
|
data_dir = Path(settings.data_dir).resolve()
|
|
if destination == data_dir or data_dir in destination.parents:
|
|
raise ValueError("Backup destination must be outside DATA_DIR")
|
|
if destination.exists():
|
|
raise ValueError("Backup destination already exists; use a new directory")
|
|
database = Database(settings)
|
|
if not database.path.exists():
|
|
raise ValueError("No initialized database found")
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = Path(tempfile.mkdtemp(prefix=".ais-backup-", dir=destination.parent))
|
|
try:
|
|
database.backup(temporary / "database.sqlite3")
|
|
artifact_dir = data_dir / "artifacts"
|
|
if artifact_dir.exists():
|
|
if artifact_dir.is_symlink():
|
|
raise ValueError("Refusing symlink in artifact storage")
|
|
# Artifacts are immutable; copying after the database snapshot includes
|
|
# every artifact referenced by that snapshot, plus possibly newer ones.
|
|
for source in artifact_dir.rglob("*"):
|
|
if source.is_symlink():
|
|
raise ValueError("Refusing symlink in artifact storage")
|
|
if source.is_file():
|
|
target = temporary / "artifacts" / source.relative_to(artifact_dir)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source, target)
|
|
config = {
|
|
"public_url": settings.public_url,
|
|
"secure_cookies": settings.secure_cookies,
|
|
"key_included": False,
|
|
"master_key_sha256": _digest(Path(settings.master_key_file)),
|
|
"format": 1,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"configuration": {
|
|
name: value for name, value in asdict(settings).items()
|
|
if name not in {"data_dir", "master_key_file", "bootstrap_username", "bootstrap_password", "testing"}
|
|
},
|
|
}
|
|
(temporary / "settings.json").write_text(json.dumps(config, indent=2), encoding="utf-8")
|
|
hashes = {path.relative_to(temporary).as_posix(): _digest(path) for path in temporary.rglob("*") if path.is_file()}
|
|
(temporary / "manifest.json").write_text(json.dumps(hashes, indent=2), encoding="utf-8")
|
|
temporary.rename(destination)
|
|
except BaseException:
|
|
if temporary.resolve().parent == destination.parent and temporary.name.startswith(".ais-backup-"):
|
|
with suppress(OSError):
|
|
shutil.rmtree(temporary)
|
|
raise
|
|
print(f"Backup complete: {destination}")
|
|
print("Encryption key is excluded. Save it separately and test restoration.")
|
|
|
|
|
|
def _invalidate_restored_state(connection: sqlite3.Connection) -> None:
|
|
"""Prevent rollback of the database from resurrecting machine credentials."""
|
|
connection.execute("DELETE FROM sessions")
|
|
connection.execute("DELETE FROM nonces")
|
|
connection.execute("UPDATE approvals SET status='revoked' WHERE status='approved'")
|
|
connection.execute("UPDATE groups SET revoked=1")
|
|
connection.execute("UPDATE hosts SET status='needs_review' WHERE id IN (SELECT host_id FROM runs WHERE status NOT IN ('succeeded','failed','cancelled','expired'))")
|
|
connection.execute("UPDATE runs SET status='needs_review' WHERE status NOT IN ('succeeded','failed','cancelled','expired')")
|
|
connection.execute(
|
|
"UPDATE runs SET device_key=NULL, bootstrap_hash=NULL, enrollment_hash=NULL, report_hash=NULL, "
|
|
"answer_until=0, enroll_until=0, lease_until=0, answer_ciphertext=NULL, bootstrap_ciphertext=NULL"
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO audit(id,actor,action,object_id,reason,data,created_at) VALUES(?,?,?,?,?,?,?)",
|
|
(str(uuid4()), "system", "backup.restore", "database", "Offline restore; credentials revoked and active runs require review", "{}", datetime.now(timezone.utc).isoformat()),
|
|
)
|
|
|
|
|
|
def restore(settings, source: Path) -> None:
|
|
"""Restore into a new data directory; the original data is never overwritten."""
|
|
from cryptography.fernet import Fernet
|
|
from provisioner.db import Database
|
|
|
|
source = source.resolve()
|
|
data_dir = Path(settings.data_dir).resolve()
|
|
if not Path(settings.master_key_file).is_file():
|
|
raise ValueError("Restore the separately protected MASTER_KEY_FILE first")
|
|
Fernet(Path(settings.master_key_file).read_bytes().strip())
|
|
if not (source / "manifest.json").is_file():
|
|
raise ValueError("Missing backup manifest")
|
|
hashes = json.loads((source / "manifest.json").read_text(encoding="utf-8"))
|
|
if "database.sqlite3" not in hashes or "settings.json" not in hashes:
|
|
raise ValueError("Incomplete backup manifest")
|
|
for name, expected in hashes.items():
|
|
original = source / name
|
|
candidate = original.resolve()
|
|
if source not in candidate.parents or original.is_symlink() or any(parent.is_symlink() for parent in original.parents if parent != source.parent) or not candidate.is_file():
|
|
raise ValueError("Unsafe or missing backup file")
|
|
if _digest(candidate) != expected:
|
|
raise ValueError(f"Backup checksum mismatch: {name}")
|
|
config = json.loads((source / "settings.json").read_text(encoding="utf-8"))
|
|
if config.get("format") != 1:
|
|
raise ValueError("Unsupported backup format")
|
|
if _digest(Path(settings.master_key_file)) != config.get("master_key_sha256"):
|
|
raise ValueError("MASTER_KEY_FILE does not match this backup")
|
|
with ServiceLock(data_dir):
|
|
existing = [path for path in data_dir.iterdir() if path.name != ".service.lock"]
|
|
if existing:
|
|
raise ValueError("Restore requires an empty DATA_DIR. Keep the old directory until validation completes.")
|
|
database = Database(settings)
|
|
shutil.copy2(source / "database.sqlite3", database.path)
|
|
try:
|
|
with closing(sqlite3.connect(database.path)) as connection, connection:
|
|
connection.execute("PRAGMA foreign_keys=ON")
|
|
if connection.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
|
|
raise ValueError("Backup database failed integrity check")
|
|
if connection.execute("PRAGMA user_version").fetchone()[0] != 1:
|
|
raise ValueError("Unsupported backup database schema")
|
|
_invalidate_restored_state(connection)
|
|
for name in hashes:
|
|
if name.startswith("artifacts/"):
|
|
target = data_dir / name
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source / name, target)
|
|
except BaseException:
|
|
# Leave failed restoration for inspection; never start it silently.
|
|
(data_dir / "RESTORE_FAILED").write_text("Restore failed. Do not start this data directory.\n", encoding="utf-8")
|
|
raise
|
|
print(f"Restored into {data_dir}; active runs need review and previous machine credentials are invalid.")
|
|
print("Review settings.json, revoke/reissue installation media, and reconcile hosts before new approvals.")
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(prog="proxmox-ais")
|
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
init_parser = commands.add_parser("init", help="Create external encryption key and initial administrator")
|
|
init_parser.add_argument("--username")
|
|
serve_parser = commands.add_parser("serve", help="Start the HTTP service behind a trusted TLS proxy")
|
|
serve_parser.add_argument("--host", default="127.0.0.1")
|
|
serve_parser.add_argument("--port", type=int, default=8080)
|
|
serve_parser.add_argument("--tls-cert", type=Path)
|
|
serve_parser.add_argument("--tls-key", type=Path)
|
|
backup_parser = commands.add_parser("backup", help="Create a consistent database and artifact snapshot")
|
|
backup_parser.add_argument("destination", type=Path)
|
|
restore_parser = commands.add_parser("restore", help="Restore offline into an empty DATA_DIR")
|
|
restore_parser.add_argument("source", type=Path)
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
settings = load_settings()
|
|
if args.command == "init":
|
|
initialize(settings, args.username)
|
|
elif args.command == "backup":
|
|
backup(settings, args.destination)
|
|
elif args.command == "restore":
|
|
restore(settings, args.source)
|
|
elif args.command == "serve":
|
|
import uvicorn
|
|
from provisioner.app import create_app
|
|
|
|
if bool(args.tls_cert) != bool(args.tls_key):
|
|
raise ValueError("Provide both --tls-cert and --tls-key")
|
|
if (Path(settings.data_dir) / "RESTORE_FAILED").exists():
|
|
raise ValueError("This data directory contains a failed restore")
|
|
uvicorn.run(
|
|
create_app(settings), host=args.host, port=args.port,
|
|
proxy_headers=False, access_log=False,
|
|
ssl_certfile=str(args.tls_cert) if args.tls_cert else None,
|
|
ssl_keyfile=str(args.tls_key) if args.tls_key else None,
|
|
)
|
|
except (OSError, ValueError, RuntimeError, sqlite3.Error) as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|