feat(selfhost): show sanitized update logs in admin UI
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user