feat(selfhost): show sanitized update logs in admin UI
All checks were successful
Build and Release FEDEO / verify (push) Successful in 48s
Build and Release FEDEO / build-and-release (push) Successful in 5m45s

This commit is contained in:
2026-09-25 18:42:26 +02:00
parent 8f06d06037
commit 29155f916a
9 changed files with 398 additions and 9 deletions

View File

@@ -14,6 +14,7 @@ import socketserver
import stat
import threading
import traceback
import urllib.parse
spec = importlib.util.spec_from_file_location("fedeo_updater", Path(__file__).with_name("selfhost-update.py"))
engine = importlib.util.module_from_spec(spec)
@@ -33,7 +34,8 @@ ERRORS = {
"COMPOSE_REVIEW_REQUIRED": "Die Compose-Datei muss zuerst auf dem Host geprüft und übernommen werden.",
"RECOVERY_REQUIRED": "Ein vorheriges Update benötigt Wiederherstellung auf dem Host.",
"PLAN_CHANGED": "Release oder Konfiguration haben sich geändert. Erneut prüfen.",
"UPDATE_FAILED": "Auftrag fehlgeschlagen. Geschützte Protokolle auf dem Host prüfen.",
"UPDATE_FAILED": "Auftrag fehlgeschlagen.",
"LOGS_UNAVAILABLE": "Das Update-Protokoll kann gerade nicht gelesen werden.",
"INTERRUPTED": "Der Auftrag wurde unterbrochen. Status auf dem Host prüfen.",
}
@@ -82,6 +84,74 @@ def public_plan(plan):
return result
LOG_BYTES = 6000
SECRET_KEY = r"[\w.-]*(?:password|passwd|secret|token|credential|authorization|(?:api|private|access|encryption|signing)[_-]?key|database[_-]?url|dsn|auth)[\w.-]*"
QUOTED_VALUE = r"\"(?:\\.|[^\"\\])*\"|'[^']*'"
SECRET_VALUE = re.compile(r"(?im)(?<![\w.-])([\"']?" + SECRET_KEY + r"[\"']?\s*[:=]\s*)(" + QUOTED_VALUE + r"|[^\r\n,}\]]+)")
CONFIG_SECRET = re.compile(r"(?im)^\s*(?:export\s+|-\s*)?[\"']?" + SECRET_KEY + r"[\"']?\s*[:=]\s*(" + QUOTED_VALUE + r"|[^\r\n]+)")
def read_private(path, *, tail=False):
"""Read only regular files, never follow a log/config symlink or block on a FIFO."""
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except FileNotFoundError:
return "", False
with os.fdopen(fd, "rb") as stream:
size = os.fstat(stream.fileno()).st_size
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
raise OSError("Not a regular file")
limit = 32768 if tail else 1024 * 1024
if size > limit and not tail:
raise OSError("Configuration too large to redact safely")
truncated = size > limit
if truncated:
stream.seek(-limit, os.SEEK_END)
stream.readline(limit) # Never expose a suffix of a secret on a cut line.
return stream.read(limit).decode("utf-8", errors="replace"), truncated
def log_redactor(directory, job):
# Include original values after an update changed the active configuration.
paths = [directory / name for name in (".env", "docker-compose.yml", "docker-compose.override.yml", "docker-compose.override.yaml")]
if job:
paths += [job / "target.env", job / "target.compose.yml"]
paths += [job / "backup/config" / name for name in (".env", "docker-compose.yml", "docker-compose.override.yml", "docker-compose.override.yaml")]
values = {value for key, value in os.environ.items() if re.fullmatch(SECRET_KEY, key, re.I) and value}
for path in paths:
content, _ = read_private(path)
for match in CONFIG_SECRET.finditer(content):
value = match[1].strip()
if value[:1] in ("'", '"'):
if value.startswith('"'):
try:
values.add(json.loads(value))
except ValueError:
pass
value = value[1:-1]
else:
value = value.split(" #", 1)[0].strip()
if value:
values.add(value)
for match in re.finditer(r"[a-z][a-z0-9+.-]*://[^\s\"']+", content, re.I):
password = urllib.parse.urlsplit(match[0]).password
if password:
values.update((password, urllib.parse.unquote(password)))
values |= {urllib.parse.quote(value, safe="") for value in values}
def redact(content):
content = re.sub(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))", "", content)
for value in sorted(values, key=len, reverse=True):
content = content.replace(value, "[REDACTED]")
content = re.sub(r"(?is)-----BEGIN [^-]*PRIVATE KEY-----.*?(?:-----END [^-]*PRIVATE KEY-----|\Z)", "[REDACTED]", content)
content = re.sub(r"(?i)([a-z][a-z0-9+.-]*://)[^\s/]*@", r"\1[REDACTED]@", content)
content = re.sub(r"(?i)\b(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]+", r"\1 [REDACTED]", content)
content = SECRET_VALUE.sub(lambda m: m[1] + "[REDACTED]", content)
content = re.sub(r"(?i)(--" + SECRET_KEY + r"\s+)(" + QUOTED_VALUE + r"|\S+)", r"\1[REDACTED]", content)
return re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", content)
return redact
class Controller:
def __init__(self, directory, *, updater_factory=engine.Updater):
self.directory = Path(directory).resolve()
@@ -140,9 +210,47 @@ class Controller:
result["progress"] = public_state(state)
return result
def request_logs(self, request_id):
record = self.read(request_id)
try:
home = self.home.parent
state = engine.read_json(home / "status.json", {})
candidates = [Path(record["job"])] if record.get("job") else []
if state.get("requestId") == request_id and state.get("job"):
candidates.append(Path(state["job"]))
# Older service versions did not persist the engine-job association.
if not candidates:
candidates = [path.parent for path in home.glob("*/status.json")]
job = None
for candidate in candidates:
if candidate.parent != home or candidate.is_symlink() or not candidate.is_dir():
continue
content, _ = read_private(candidate / "status.json")
if content and json.loads(content).get("requestId") == request_id:
job = candidate
break
redact = log_redactor(self.directory, job)
files = [("service", self.home / (request_id + ".log"))]
if job:
files += [("events", job / "events.jsonl"), ("commands", job / "commands.log")]
logs = []
for source, path in files:
content, truncated = read_private(path, tail=True)
content = redact(content)
encoded = content.encode("utf-8")
if len(encoded) > LOG_BYTES:
content = encoded[-LOG_BYTES:].decode("utf-8", errors="ignore")
truncated = True
if content or truncated:
logs.append({"source": source, "content": content, "truncated": truncated})
return {"requestId": request_id, "logs": logs}
except (OSError, ValueError, TypeError):
raise ApiError("LOGS_UNAVAILABLE", 503) from None
def status(self):
updater = self.factory(self.directory)
result = {"apiVersion": 1, "engine": public_state(updater.status()), "acceptingRequests": not self.closing}
result = {"apiVersion": 1, "engine": public_state(updater.status()), "acceptingRequests": not self.closing,
"capabilities": {"requestLogs": True}}
installed = engine.read_json(updater.home / "installed.json", {}).get("manifest", {})
result["installed"] = {k: v for k, v in public_state({"version": installed.get("version"),
"revision": installed.get("commit")}).items() if k != "status"}
@@ -208,6 +316,7 @@ class Controller:
def run(self, record, fingerprint):
request_id = record["requestId"]
updater = None
try:
record.update(status="running", updatedAt=engine.now())
engine.write_json(self.path(request_id), record)
@@ -224,6 +333,8 @@ class Controller:
os.chmod(log.name, 0o600)
traceback.print_exc(file=log)
finally:
if updater and updater.state and updater.state.get("requestId") == request_id:
record["job"] = str(updater.job)
record["updatedAt"] = engine.now()
engine.write_json(self.path(request_id), record)
@@ -267,7 +378,9 @@ class Handler(BaseHTTPRequestHandler):
if self.command == "GET" and self.path == "/v1/status":
self.send_json(200, controller.status())
elif self.command == "GET" and self.path.startswith("/v1/requests/"):
self.send_json(200, controller.request_status(self.path.removeprefix("/v1/requests/")))
suffix = self.path.removeprefix("/v1/requests/")
self.send_json(200, controller.request_logs(suffix.removesuffix("/logs"))
if suffix.endswith("/logs") else controller.request_status(suffix))
elif self.command == "POST" and self.path in ("/v1/check", "/v1/apply"):
lengths = self.headers.get_all("Content-Length", [])
if (len(lengths) != 1 or not lengths[0].isdigit() or not 1 <= int(lengths[0]) <= 4096