You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
332 lines
17 KiB
332 lines
17 KiB
#!/usr/bin/env python3
|
|
"""Host-only updater for one explicitly configured Vaultwarden Compose service."""
|
|
|
|
import argparse
|
|
import copy
|
|
import fcntl
|
|
import http.server
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import socketserver
|
|
import subprocess
|
|
import tarfile
|
|
import threading
|
|
import time
|
|
import uuid
|
|
|
|
|
|
CAPABILITY_LABEL = "org.vaultwarden.admin-updates"
|
|
IMAGE_PATTERN = re.compile(r"([a-z0-9][a-z0-9._:/-]*)(?::([\w][\w.-]{0,127})|@(sha256:[a-f0-9]{64}))", re.ASCII)
|
|
|
|
|
|
class UpdateError(Exception):
|
|
pass
|
|
|
|
|
|
def atomic_json(path, value):
|
|
temporary = path.with_suffix(".tmp")
|
|
with temporary.open("w", encoding="utf-8") as file:
|
|
json.dump(value, file, indent=2)
|
|
file.flush()
|
|
os.fsync(file.fileno())
|
|
temporary.replace(path)
|
|
directory = os.open(path.parent, os.O_RDONLY)
|
|
try:
|
|
os.fsync(directory)
|
|
finally:
|
|
os.close(directory)
|
|
|
|
|
|
def validate_image(image, repositories):
|
|
if not isinstance(image, str) or len(image) > 512:
|
|
raise UpdateError("Enter a Docker image with an explicit tag or digest.")
|
|
match = IMAGE_PATTERN.fullmatch(image)
|
|
if not match or match.group(1) not in repositories:
|
|
raise UpdateError("Use an approved image repository with an explicit tag or digest.")
|
|
return image
|
|
|
|
|
|
class Docker:
|
|
def run(self, *args, timeout=60):
|
|
# Never run a shell, and never expose Docker output (which may contain secrets) to the browser.
|
|
try:
|
|
result = subprocess.run(
|
|
["docker", *args], capture_output=True, text=True, timeout=timeout, check=False
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as error:
|
|
raise UpdateError("Docker is unavailable or the operation timed out. Check the host.") from error
|
|
if result.returncode:
|
|
raise UpdateError("Docker rejected the operation. Check the configured service, image, and registry access on the host.")
|
|
return result.stdout.strip()
|
|
|
|
|
|
class Updater:
|
|
def __init__(self, config, docker=None):
|
|
self.config = config
|
|
self.docker = docker or Docker()
|
|
self.lock = threading.RLock()
|
|
self.state_dir = Path(config["state_directory"]).resolve(strict=True)
|
|
self.data_dir = Path(config["data_directory"]).resolve(strict=True)
|
|
self.project_dir = Path(config["project_directory"]).resolve(strict=True)
|
|
if self.data_dir == Path("/") or self.state_dir.is_relative_to(self.data_dir) or self.data_dir.is_relative_to(self.state_dir):
|
|
raise UpdateError("Use separate data and updater state directories, neither containing the other.")
|
|
self.override = self.state_dir / "image.override.json"
|
|
self.state_path = self.state_dir / "status.json"
|
|
validate_image(config["default_image"], config["image_repositories"])
|
|
self.state = {
|
|
"busy": False, "recovery_required": False, "current_image": None,
|
|
"default_image": config["default_image"], "candidate": None,
|
|
"message": "", "result": None, "events": [],
|
|
}
|
|
if self.state_path.exists():
|
|
self.state.update(json.loads(self.state_path.read_text()))
|
|
self.state["default_image"] = config["default_image"]
|
|
if self.state["busy"]:
|
|
self.state.update(busy=False, recovery_required=True, candidate=None)
|
|
self.event("The updater was interrupted. Inspect the host before allowing another deployment.")
|
|
|
|
def compose(self, *args, override=True, timeout=60):
|
|
command = ["compose", "--project-directory", str(self.project_dir), "--project-name", self.config["project"]]
|
|
for file in self.config["compose_files"]:
|
|
command += ["--file", str(Path(file).resolve(strict=True))]
|
|
if override and self.override.exists():
|
|
command += ["--file", str(self.override)]
|
|
return self.docker.run(*command, *args, timeout=timeout)
|
|
|
|
def container(self):
|
|
ids = self.compose("ps", "--all", "--quiet", self.config["service"]).splitlines()
|
|
if len(ids) != 1:
|
|
raise UpdateError("The updater requires exactly one existing Compose container for the configured service.")
|
|
result = json.loads(self.docker.run("inspect", "--type", "container", ids[0]))[0]
|
|
labels = result["Config"].get("Labels") or {}
|
|
if labels.get("com.docker.compose.project") != self.config["project"] or labels.get("com.docker.compose.service") != self.config["service"]:
|
|
raise UpdateError("The target container does not match the configured Compose project and service.")
|
|
return result
|
|
|
|
def preflight(self, container):
|
|
configured_hash = self.compose("config", "--hash", self.config["service"]).split()
|
|
running_hash = container["Config"].get("Labels", {}).get("com.docker.compose.config-hash")
|
|
if len(configured_hash) != 2 or configured_hash[1] != running_hash:
|
|
raise UpdateError("Compose configuration differs from the running service. Reconcile it on the host before updating.")
|
|
if not container["State"]["Running"]:
|
|
raise UpdateError("Start and verify the existing service before updating it.")
|
|
if container["State"].get("Health", {}).get("Status") != "healthy":
|
|
raise UpdateError("The existing service must have a passing Docker health check.")
|
|
mounts = container["Mounts"]
|
|
data_mount = [m for m in mounts if m["Destination"] == "/data"]
|
|
if len(data_mount) != 1 or data_mount[0]["Type"] != "bind" or Path(data_mount[0]["Source"]).resolve() != self.data_dir or not data_mount[0]["RW"]:
|
|
raise UpdateError("This updater requires the configured data directory bind-mounted read/write at /data.")
|
|
if any(m["RW"] and m["Destination"] != "/data" for m in mounts):
|
|
raise UpdateError("Additional writable mounts need a separate backup plan before updates can be enabled.")
|
|
settings = dict(entry.split("=", 1) for entry in container["Config"].get("Env", []) if "=" in entry)
|
|
if "ENV_FILE" in settings or any(m["Destination"] == "/.env" for m in mounts):
|
|
raise UpdateError("External environment files require host-managed updates.")
|
|
for key in ("DATABASE_URL", "DATA_FOLDER", "CONFIG_FILE", "ATTACHMENTS_FOLDER", "SENDS_FOLDER", "RSA_KEY_FILENAME"):
|
|
if f"{key}_FILE" in settings:
|
|
raise UpdateError("File-based storage configuration requires host-managed updates.")
|
|
config_file = self.data_dir / "config.json"
|
|
if config_file.exists():
|
|
saved = json.loads(config_file.read_text())
|
|
settings.update({key.upper(): value for key, value in saved.items() if value is not None})
|
|
defaults = {
|
|
"DATA_FOLDER": "/data", "DATABASE_URL": "sqlite:///data/db.sqlite3",
|
|
"ATTACHMENTS_FOLDER": "/data/attachments", "SENDS_FOLDER": "/data/sends",
|
|
"RSA_KEY_FILENAME": "/data/rsa_key", "CONFIG_FILE": "/data/config.json",
|
|
}
|
|
for key, default in defaults.items():
|
|
path = str(settings.get(key, default))
|
|
if key in ("DATA_FOLDER", "CONFIG_FILE") and path != default:
|
|
raise UpdateError("Automatic updates require DATA_FOLDER=/data and CONFIG_FILE=/data/config.json.")
|
|
if key == "DATABASE_URL":
|
|
path = path.removeprefix("sqlite://")
|
|
if not path.startswith("/data/") and path != "/data":
|
|
raise UpdateError("Automatic updates require SQLite and all persistent vault data under /data.")
|
|
if ".." in Path(path).parts:
|
|
raise UpdateError("Persistent data paths must stay inside /data.")
|
|
for path in self.data_dir.rglob("*"):
|
|
if path.is_symlink() or not (path.is_file() or path.is_dir()):
|
|
raise UpdateError("The data directory contains links or special files. Set up a complete backup plan first.")
|
|
|
|
def event(self, message, **values):
|
|
with self.lock:
|
|
self.state.update(values)
|
|
self.state["message"] = message
|
|
self.state["events"].append({"time": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()), "message": message})
|
|
self.state["events"] = self.state["events"][-30:]
|
|
atomic_json(self.state_path, self.state)
|
|
|
|
def status(self):
|
|
with self.lock:
|
|
return copy.deepcopy(self.state)
|
|
|
|
def submit(self):
|
|
with self.lock:
|
|
if self.state["busy"] or self.state["recovery_required"]:
|
|
raise UpdateError("A deployment is active or requires host recovery. Refresh status before continuing.")
|
|
self.event("Updating…", busy=True, candidate=None, result=None)
|
|
threading.Thread(target=self.work, args=(self.update,), daemon=False).start()
|
|
return self.status()
|
|
|
|
def update(self):
|
|
image = validate_image(self.config["default_image"], self.config["image_repositories"])
|
|
candidate = self.check(image)
|
|
if candidate["update_available"]:
|
|
self.deploy(candidate)
|
|
else:
|
|
self.event("Already up to date.", busy=False, result="unchanged", candidate=None)
|
|
|
|
def work(self, operation):
|
|
try:
|
|
operation()
|
|
except Exception as error:
|
|
message = str(error) if isinstance(error, UpdateError) else "The updater failed. Inspect the host before retrying."
|
|
self.event(message, busy=False, candidate=None, result="failed")
|
|
|
|
def check(self, image):
|
|
current = self.container()
|
|
self.preflight(current)
|
|
self.event("Downloading the selected image. The vault is still running.", current_image=current["Config"]["Image"])
|
|
self.docker.run("pull", image, timeout=900)
|
|
candidate = json.loads(self.docker.run("image", "inspect", image))[0]
|
|
labels = candidate["Config"].get("Labels") or {}
|
|
if labels.get(CAPABILITY_LABEL) != "1":
|
|
raise UpdateError("This image does not include the admin update feature. Choose an image built with admin update support.")
|
|
healthcheck = candidate["Config"].get("Healthcheck", {}).get("Test", [])
|
|
if not healthcheck or healthcheck[0] == "NONE":
|
|
raise UpdateError("The selected image must include a Docker health check.")
|
|
available = current["Image"] != candidate["Id"]
|
|
return {"check_id": str(uuid.uuid4()), "image": image, "image_id": candidate["Id"],
|
|
"previous_id": current["Image"], "container_id": current["Id"], "update_available": available}
|
|
|
|
def deploy(self, candidate):
|
|
# Repeat all checks: the service could have changed since the image was downloaded.
|
|
current = self.container()
|
|
self.preflight(current)
|
|
validate_image(candidate["image"], self.config["image_repositories"])
|
|
if current["Id"] != candidate["container_id"] or current["Image"] != candidate["previous_id"]:
|
|
raise UpdateError("The running service changed. Check the image again.")
|
|
self.docker.run("image", "inspect", candidate["image_id"])
|
|
deployment = self.state_dir / "backups" / candidate["check_id"]
|
|
deployment.mkdir(parents=True, exist_ok=False)
|
|
atomic_json(deployment / "deployment.json", {
|
|
"previous_image": current["Image"], "target_image": candidate["image_id"],
|
|
"requested_image": candidate["image"], "service": self.config["service"],
|
|
})
|
|
# Record the recovery boundary BEFORE requesting a stop: even a timed-out CLI may have stopped it.
|
|
self.event("Stopping the vault for a consistent data backup…", recovery_required=True)
|
|
self.compose("stop", "--timeout", "30", self.config["service"], timeout=120)
|
|
if self.container()["State"]["Running"]:
|
|
raise UpdateError("The vault did not stop. Deployment was cancelled; inspect the host.")
|
|
self.event("Backing up all vault data…")
|
|
archive_path = deployment / "data.tar"
|
|
with tarfile.open(archive_path.with_suffix(".partial"), "w") as archive:
|
|
archive.add(self.data_dir, arcname="data")
|
|
with archive_path.with_suffix(".partial").open("rb") as archive:
|
|
os.fsync(archive.fileno())
|
|
archive_path.with_suffix(".partial").replace(archive_path)
|
|
self.event("Starting the downloaded image…")
|
|
atomic_json(self.override, {"services": {self.config["service"]: {"image": candidate["image_id"]}}})
|
|
self.compose("up", "--detach", "--no-deps", "--no-build", "--pull", "never", "--force-recreate", self.config["service"], timeout=180)
|
|
self.event("Waiting for the new container to become healthy…")
|
|
deadline = time.monotonic() + self.config.get("health_timeout", 180)
|
|
while time.monotonic() < deadline:
|
|
container = self.container()
|
|
if container["Image"] != candidate["image_id"]:
|
|
raise UpdateError("The running image differs from the selected image. Inspect the host.")
|
|
health = container["State"].get("Health", {}).get("Status")
|
|
if container["State"]["Running"] and health == "healthy":
|
|
self.event("Update completed. The new container is healthy.", busy=False, recovery_required=False,
|
|
current_image=candidate["image"], candidate=None, result="updated")
|
|
return
|
|
if not container["State"]["Running"] or health == "unhealthy":
|
|
break
|
|
time.sleep(2)
|
|
# Never silently restore an old database after a new version may have accepted writes.
|
|
raise UpdateError("The new container did not become healthy. Host recovery is required; the data backup is retained.")
|
|
|
|
|
|
class Handler(http.server.BaseHTTPRequestHandler):
|
|
def setup(self):
|
|
super().setup()
|
|
self.connection.settimeout(15)
|
|
|
|
def log_message(self, *_args):
|
|
pass
|
|
|
|
def reply(self, code, data):
|
|
body = json.dumps(data).encode()
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_GET(self):
|
|
if self.path != "/status":
|
|
self.reply(404, {"error": "Not found."})
|
|
return
|
|
self.reply(200, self.server.updater.status())
|
|
|
|
def do_POST(self):
|
|
if self.path != "/update":
|
|
self.reply(404, {"error": "Not found."})
|
|
return
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
if length <= 0 or length > 4096 or self.headers.get_content_type() != "application/json":
|
|
raise ValueError()
|
|
payload = json.loads(self.rfile.read(length))
|
|
if not isinstance(payload, dict):
|
|
raise ValueError()
|
|
except (ValueError, UnicodeDecodeError):
|
|
self.reply(400, {"error": "Expected a small JSON object."})
|
|
return
|
|
try:
|
|
if payload:
|
|
raise UpdateError("The update target is configured on the host.")
|
|
self.reply(202, self.server.updater.submit())
|
|
except UpdateError as error:
|
|
self.reply(409, {"error": str(error)})
|
|
|
|
|
|
class Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
|
|
daemon_threads = True
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("config", type=Path)
|
|
parser.add_argument("--acknowledge-recovery", action="store_true",
|
|
help="Clear the recovery lock after an operator has repaired and verified the service")
|
|
args = parser.parse_args()
|
|
os.umask(0o077)
|
|
config = json.loads(args.config.read_text())
|
|
state_dir = Path(config["state_directory"])
|
|
state_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
# Keep the file descriptor open to exclude a second updater for this state directory.
|
|
with (state_dir / "updater.lock").open("w") as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
updater = Updater(config)
|
|
if args.acknowledge_recovery:
|
|
current = updater.container()
|
|
updater.preflight(current)
|
|
updater.event("Server recovery confirmed.", busy=False, recovery_required=False,
|
|
candidate=None, result="recovered", current_image=current["Config"]["Image"])
|
|
return
|
|
socket_path = Path(config["socket"])
|
|
socket_path.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
|
|
if socket_path.exists():
|
|
if not socket_path.is_socket():
|
|
raise UpdateError("The configured socket path already exists and is not a socket.")
|
|
socket_path.unlink()
|
|
with Server(str(socket_path), Handler) as server:
|
|
os.chmod(socket_path, 0o660)
|
|
server.updater = updater
|
|
server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|