Initial server source import
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
FROM caddy:2.10.2-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d AS caddy-edge-e2e
|
||||
|
||||
FROM debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 AS fail2ban-edge-e2e
|
||||
|
||||
COPY --from=caddy-edge-e2e --chown=0:0 --chmod=0444 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
RUN <<'SH'
|
||||
set -eu
|
||||
rm -f /etc/apt/sources.list /etc/apt/sources.list.d/debian.sources
|
||||
printf '%s\n' \
|
||||
'Types: deb' \
|
||||
'URIs: http://snapshot.debian.org/archive/debian/20260914T000000Z/' \
|
||||
'Suites: bookworm bookworm-updates' \
|
||||
'Components: main' \
|
||||
'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \
|
||||
'Check-Valid-Until: no' \
|
||||
'' \
|
||||
'Types: deb' \
|
||||
'URIs: http://snapshot.debian.org/archive/debian-security/20260914T000000Z/' \
|
||||
'Suites: bookworm-security' \
|
||||
'Components: main' \
|
||||
'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \
|
||||
'Check-Valid-Until: no' \
|
||||
> /etc/apt/sources.list.d/debian.sources
|
||||
printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d
|
||||
chmod 0755 /usr/sbin/policy-rc.d
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get -o Acquire::Retries=3 -o Acquire::https::Timeout=30 -o APT::Update::Error-Mode=any update
|
||||
apt-get install -y --no-install-recommends fail2ban=1.0.2-2
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* /var/log/apt/*
|
||||
find /etc/fail2ban/jail.d -type f -delete
|
||||
install -d -o 0 -g 0 -m 0755 \
|
||||
/etc/caddy /etc/caddy/denylist /etc/caddy/tls /etc/fail2ban/action.d /etc/fail2ban/fail2ban.d \
|
||||
/etc/fail2ban/filter.d /etc/fail2ban/jail.d /etc/truf-edge/denylist \
|
||||
/run/fail2ban /var/lib/fail2ban /var/lib/truf-edge /var/log/caddy /var/log/truf-edge
|
||||
SH
|
||||
|
||||
COPY --from=caddy-edge-e2e --chown=0:0 --chmod=0555 /usr/bin/caddy /usr/bin/caddy
|
||||
RUN cp /usr/bin/caddy /usr/bin/caddy-edge-e2e \
|
||||
&& rm /usr/bin/caddy \
|
||||
&& mv /usr/bin/caddy-edge-e2e /usr/bin/caddy \
|
||||
&& chmod 0555 /usr/bin/caddy
|
||||
COPY --chown=0:0 --chmod=0444 deploy/edge/Caddyfile /etc/caddy/Caddyfile
|
||||
RUN chmod 0644 /etc/caddy/Caddyfile \
|
||||
&& sed -i 's/^[[:space:]]*admin off$/\tadmin 127.0.0.1:2019/' /etc/caddy/Caddyfile \
|
||||
&& chmod 0444 /etc/caddy/Caddyfile \
|
||||
&& grep -Fx ' admin 127.0.0.1:2019' /etc/caddy/Caddyfile >/dev/null
|
||||
COPY --chown=0:0 --chmod=0444 deploy/fail2ban/filter.d-truf-admin-auth.conf /etc/fail2ban/filter.d/truf-admin-auth.conf
|
||||
COPY --chown=0:0 --chmod=0444 deploy/fail2ban/jail.d-truf-admin-auth.local /etc/fail2ban/jail.d/truf-admin-auth.local
|
||||
COPY --chown=0:0 --chmod=0444 deploy/fail2ban/action.d-truf-caddy-admin-denylist.conf /etc/fail2ban/action.d/truf-caddy-admin-denylist.conf
|
||||
COPY --chown=0:0 --chmod=0444 deploy/fail2ban/fail2ban.d-truf-persistence.local /etc/fail2ban/fail2ban.d/truf-persistence.local
|
||||
COPY --chown=0:0 --chmod=0444 deploy/fail2ban/fail2ban.d-edge-e2e.local /etc/fail2ban/fail2ban.d/edge-e2e.local
|
||||
COPY --chown=0:0 --chmod=0555 deploy/fail2ban/truf_caddy_admin_denylist.py /usr/local/sbin/truf-caddy-admin-denylist
|
||||
COPY --chown=0:0 --chmod=0555 deploy/fail2ban/edge_e2e_docker_shim.py /usr/local/bin/docker
|
||||
|
||||
RUN fail2ban-server --version 2>&1 | grep -F 'v1.0.2' >/dev/null \
|
||||
&& test "$(find /etc/fail2ban/jail.d -type f | wc -l)" -eq 1
|
||||
|
||||
USER 0:0
|
||||
ENTRYPOINT ["/usr/bin/fail2ban-server", "-f", "-x"]
|
||||
CMD []
|
||||
@@ -0,0 +1,4 @@
|
||||
[Definition]
|
||||
actionstart = /usr/local/sbin/truf-caddy-admin-denylist expire
|
||||
actionban = /usr/local/sbin/truf-caddy-admin-denylist ban '<ip>'
|
||||
actionunban = /usr/local/sbin/truf-caddy-admin-denylist unban '<ip>'
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/python3
|
||||
"""Constrain production denylist reload commands to the colocated E2E Caddy."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
ENV_FILE = Path("/etc/truf-edge/edge.env")
|
||||
AUDIT_PATH = Path("/var/lib/truf-edge/edge-e2e-reload.audit")
|
||||
VALIDATION_ERROR_PATH = Path("/var/lib/truf-edge/edge-e2e-validation.error")
|
||||
COMPOSE_PREFIX = (
|
||||
"compose", "--ansi", "never", "--env-file", str(ENV_FILE),
|
||||
"--project-directory", "/opt/truf",
|
||||
"--file", "/opt/truf/compose.yaml",
|
||||
"--file", "/opt/truf/compose.edge.yaml",
|
||||
)
|
||||
VALIDATE_COMMAND = COMPOSE_PREFIX + (
|
||||
"exec", "-T", "edge", "caddy", "validate",
|
||||
"--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile",
|
||||
)
|
||||
RELOAD_COMMAND = COMPOSE_PREFIX + ("kill", "--signal", "SIGUSR1", "edge")
|
||||
REQUIRED_ENV = {
|
||||
"TRUF_EDGE_HOST",
|
||||
"TRUF_EDGE_TLS_INCLUDE",
|
||||
"TRUF_ADMIN_PREFIX",
|
||||
"TRUF_ADMIN_USER",
|
||||
"TRUF_ADMIN_PASSWORD_HASH",
|
||||
"TRUF_ADMIN_EDGE_MARKER",
|
||||
}
|
||||
|
||||
|
||||
def classify_command(arguments):
|
||||
command = tuple(arguments)
|
||||
if command == VALIDATE_COMMAND:
|
||||
return "validate"
|
||||
if command == RELOAD_COMMAND:
|
||||
return "reload"
|
||||
raise ValueError("unsupported command")
|
||||
|
||||
|
||||
def audit(operation, result):
|
||||
payload = f"{operation}:{result}\n".encode("ascii")
|
||||
flags = (
|
||||
os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_BINARY", 0)
|
||||
)
|
||||
descriptor = os.open(AUDIT_PATH, flags, 0o600)
|
||||
try:
|
||||
details = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(details.st_mode) or details.st_size + len(payload) > 4096:
|
||||
raise ValueError("invalid audit file")
|
||||
os.write(descriptor, payload)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def record_validation_error(content, environment):
|
||||
if len(content) > 65536:
|
||||
content = b"caddy validation error exceeded evidence bound\n"
|
||||
text = content.decode("utf-8", errors="replace")
|
||||
for value in environment.values():
|
||||
if value:
|
||||
text = text.replace(value, "[redacted]")
|
||||
text = re.sub(r"\$2[aby]\$(?:0[4-9]|[12][0-9]|3[01])\$[./A-Za-z0-9]{53}", "[redacted]", text)
|
||||
text = re.sub(r"(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])", "[redacted]", text)
|
||||
payload = text.encode("utf-8", errors="replace")[:4096]
|
||||
descriptor = os.open(
|
||||
VALIDATION_ERROR_PATH,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_BINARY", 0),
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
||||
raise ValueError("invalid validation evidence file")
|
||||
os.write(descriptor, payload)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def load_environment(path=ENV_FILE):
|
||||
details = path.lstat()
|
||||
if not stat.S_ISREG(details.st_mode) or stat.S_ISLNK(details.st_mode) or details.st_size > 8192:
|
||||
raise ValueError("invalid environment file")
|
||||
values = {}
|
||||
for raw_line in path.read_text(encoding="ascii").splitlines():
|
||||
if not raw_line or raw_line.startswith("#"):
|
||||
continue
|
||||
name, separator, value = raw_line.partition("=")
|
||||
if not separator or name not in REQUIRED_ENV or name in values or "\x00" in value:
|
||||
raise ValueError("invalid environment entry")
|
||||
values[name] = value
|
||||
if set(values) != REQUIRED_ENV:
|
||||
raise ValueError("incomplete environment")
|
||||
if (
|
||||
values["TRUF_EDGE_HOST"] != "localhost"
|
||||
or values["TRUF_EDGE_TLS_INCLUDE"] != "/etc/caddy/tls/static-tls.caddy"
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", values["TRUF_ADMIN_PREFIX"])
|
||||
or not re.fullmatch(r"[A-Za-z0-9_.-]{1,64}", values["TRUF_ADMIN_USER"])
|
||||
or not re.fullmatch(r"\$2[aby]\$(?:0[4-9]|[12][0-9]|3[01])\$[./A-Za-z0-9]{53}", values["TRUF_ADMIN_PASSWORD_HASH"])
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", values["TRUF_ADMIN_EDGE_MARKER"])
|
||||
):
|
||||
raise ValueError("unsupported environment")
|
||||
return {
|
||||
**values,
|
||||
"HOME": "/tmp",
|
||||
"LANG": "C.UTF-8",
|
||||
"LC_ALL": "C.UTF-8",
|
||||
"PATH": "/usr/bin:/bin",
|
||||
}
|
||||
|
||||
|
||||
def validate():
|
||||
try:
|
||||
environment = load_environment()
|
||||
except Exception:
|
||||
audit("environment", 64)
|
||||
raise
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
(
|
||||
"/usr/bin/caddy", "validate", "--config", "/etc/caddy/Caddyfile",
|
||||
"--adapter", "caddyfile",
|
||||
),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
env=environment,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
audit("caddy-exec", 64)
|
||||
raise
|
||||
if completed.returncode:
|
||||
record_validation_error(completed.stderr, environment)
|
||||
return completed.returncode
|
||||
|
||||
|
||||
def reload_caddy():
|
||||
try:
|
||||
command = Path("/proc/1/cmdline").read_bytes()
|
||||
except Exception:
|
||||
audit("reload-proc", 64)
|
||||
raise
|
||||
if (
|
||||
len(command) > 4096
|
||||
or command.rstrip(b"\0").split(b"\0")
|
||||
not in (
|
||||
[b"caddy", b"run", b"--config", b"/etc/caddy/Caddyfile", b"--adapter", b"caddyfile"],
|
||||
[b"/usr/bin/caddy", b"run", b"--config", b"/etc/caddy/Caddyfile", b"--adapter", b"caddyfile"],
|
||||
)
|
||||
):
|
||||
audit("reload-identity", 64)
|
||||
raise ValueError("unexpected pid namespace")
|
||||
completed = subprocess.run(
|
||||
(
|
||||
"/usr/bin/caddy", "reload", "--config", "/etc/caddy/Caddyfile",
|
||||
"--adapter", "caddyfile", "--address", "127.0.0.1:2019",
|
||||
),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=load_environment(),
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
return completed.returncode
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
try:
|
||||
operation = classify_command((argv or sys.argv)[1:])
|
||||
result = validate() if operation == "validate" else reload_caddy()
|
||||
except Exception:
|
||||
if "operation" in locals():
|
||||
try:
|
||||
audit(operation, 64)
|
||||
except Exception:
|
||||
pass
|
||||
return 64
|
||||
try:
|
||||
audit(operation, result)
|
||||
except Exception:
|
||||
return 64
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
[Definition]
|
||||
loglevel = INFO
|
||||
logtarget = STDOUT
|
||||
socket = /run/fail2ban/fail2ban.sock
|
||||
pidfile = /run/fail2ban/fail2ban.pid
|
||||
@@ -0,0 +1,3 @@
|
||||
[Definition]
|
||||
dbfile = /var/lib/fail2ban/fail2ban.sqlite3
|
||||
dbpurgeage = 7d
|
||||
@@ -0,0 +1,4 @@
|
||||
[Definition]
|
||||
failregex = ^(?=.{1,1024}$)(?=.*"event"\s*:\s*"admin_auth_failure")(?=.*"status"\s*:\s*401)(?=.*"remote_ip"\s*:\s*"<HOST>")(?!.*"(?:request|uri|headers|authorization|password|token|prefix)"\s*:).*\s*$
|
||||
ignoreregex =
|
||||
datepattern = "ts":{EPOCH}
|
||||
@@ -0,0 +1,9 @@
|
||||
[truf-admin-auth]
|
||||
enabled = true
|
||||
filter = truf-admin-auth
|
||||
logpath = /var/log/truf-edge/admin-auth-failures.json
|
||||
backend = auto
|
||||
maxretry = 2
|
||||
findtime = 10m
|
||||
bantime = 24h
|
||||
action = truf-caddy-admin-denylist
|
||||
@@ -0,0 +1,438 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Maintain the Caddy admin-only IP denylist with durable expiry state."""
|
||||
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
BAN_SECONDS = 24 * 60 * 60
|
||||
MAX_BANS = 4096
|
||||
MAX_FILE_BYTES = 512 * 1024
|
||||
STATE_VERSION = 1
|
||||
EMPTY_SNIPPET = "# Managed by truf-caddy-admin-denylist. Admin-route import only.\n"
|
||||
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
PROFILE_PATH = Path("/etc/truf/deployment-profile")
|
||||
STANDALONE_PROFILE = "standalone-edge-v1"
|
||||
SHARED_HOST_PROFILE = "shared-host-edge-v1"
|
||||
|
||||
|
||||
class UpdateError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class CommandFailure(UpdateError):
|
||||
pass
|
||||
|
||||
|
||||
class RollbackFailure(UpdateError):
|
||||
pass
|
||||
|
||||
|
||||
def canonical_ip(value):
|
||||
text = str(value or "")
|
||||
if not text or len(text) > 64 or "%" in text or any(char.isspace() for char in text):
|
||||
raise UpdateError("invalid IP address")
|
||||
try:
|
||||
address = ipaddress.ip_address(text)
|
||||
except ValueError as exc:
|
||||
raise UpdateError("invalid IP address") from exc
|
||||
if address.is_unspecified or address.is_multicast:
|
||||
raise UpdateError("unsupported IP address")
|
||||
return address.compressed.lower()
|
||||
|
||||
|
||||
def render_snippet(bans, matcher="remote_ip"):
|
||||
if matcher not in {"remote_ip", "client_ip"}:
|
||||
raise UpdateError("unsupported denylist matcher")
|
||||
addresses = sorted(
|
||||
(ipaddress.ip_address(address) for address in bans),
|
||||
key=lambda address: (address.version, int(address)),
|
||||
)
|
||||
if not addresses:
|
||||
return EMPTY_SNIPPET.encode("ascii")
|
||||
lines = [EMPTY_SNIPPET.rstrip("\n")]
|
||||
for offset in range(0, len(addresses), 64):
|
||||
name = f"truf_admin_denied_{offset // 64:04d}"
|
||||
values = " ".join(address.compressed.lower() for address in addresses[offset:offset + 64])
|
||||
lines.append(f"@{name} {matcher} {values}")
|
||||
lines.append(f'respond @{name} "" 403')
|
||||
return ("\n".join(lines) + "\n").encode("ascii")
|
||||
|
||||
|
||||
def _digest(content):
|
||||
return hashlib.sha256(content).hexdigest()
|
||||
|
||||
|
||||
def _check_parent(path):
|
||||
parent = path.parent
|
||||
details = parent.lstat()
|
||||
if not stat.S_ISDIR(details.st_mode) or stat.S_ISLNK(details.st_mode):
|
||||
raise UpdateError("managed parent must be a real directory")
|
||||
if os.name == "posix" and stat.S_IMODE(details.st_mode) & 0o002:
|
||||
raise UpdateError("managed parent must not be world-writable")
|
||||
|
||||
|
||||
def _read_optional(path):
|
||||
_check_parent(path)
|
||||
try:
|
||||
details = path.lstat()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if not stat.S_ISREG(details.st_mode) or stat.S_ISLNK(details.st_mode):
|
||||
raise UpdateError("managed path must be a regular file")
|
||||
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
current = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(current.st_mode) or current.st_size > MAX_FILE_BYTES:
|
||||
raise UpdateError("managed file is invalid or too large")
|
||||
chunks = []
|
||||
remaining = MAX_FILE_BYTES + 1
|
||||
while remaining:
|
||||
chunk = os.read(descriptor, min(65536, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
content = b"".join(chunks)
|
||||
if len(content) > MAX_FILE_BYTES:
|
||||
raise UpdateError("managed file is too large")
|
||||
return content
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _sync_parent(parent):
|
||||
if os.name != "posix":
|
||||
return
|
||||
descriptor = os.open(parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _atomic_write(path, content, mode):
|
||||
_check_parent(path)
|
||||
if len(content) > MAX_FILE_BYTES:
|
||||
raise UpdateError("managed content is too large")
|
||||
try:
|
||||
existing = path.lstat()
|
||||
except FileNotFoundError:
|
||||
existing = None
|
||||
if existing is not None and (not stat.S_ISREG(existing.st_mode) or stat.S_ISLNK(existing.st_mode)):
|
||||
raise UpdateError("managed path must be a regular file")
|
||||
descriptor, temporary = tempfile.mkstemp(prefix=".truf-denylist-", dir=path.parent)
|
||||
temporary_path = Path(temporary)
|
||||
try:
|
||||
if hasattr(os, "fchmod"):
|
||||
os.fchmod(descriptor, mode)
|
||||
else:
|
||||
os.chmod(temporary_path, mode)
|
||||
with os.fdopen(descriptor, "wb", closefd=True) as handle:
|
||||
descriptor = -1
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary_path, path)
|
||||
_sync_parent(path.parent)
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
try:
|
||||
temporary_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _restore(path, content, mode):
|
||||
if content is not None:
|
||||
_atomic_write(path, content, mode)
|
||||
return
|
||||
try:
|
||||
details = path.lstat()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
if not stat.S_ISREG(details.st_mode) or stat.S_ISLNK(details.st_mode):
|
||||
raise UpdateError("managed path changed during rollback")
|
||||
path.unlink()
|
||||
_sync_parent(path.parent)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_lock(path):
|
||||
_check_parent(path)
|
||||
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
try:
|
||||
details = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(details.st_mode):
|
||||
raise UpdateError("lock path must be a regular file")
|
||||
if os.name == "posix":
|
||||
import fcntl
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _load_state(content):
|
||||
if content is None:
|
||||
return {"version": STATE_VERSION, "bans": {}, "applied_sha256": ""}
|
||||
try:
|
||||
value = json.loads(content.decode("ascii"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise UpdateError("denylist state is not valid JSON") from exc
|
||||
if not isinstance(value, dict) or set(value) != {"version", "bans", "applied_sha256"}:
|
||||
raise UpdateError("denylist state has an invalid schema")
|
||||
if value["version"] != STATE_VERSION or not isinstance(value["bans"], dict):
|
||||
raise UpdateError("denylist state has an unsupported version")
|
||||
if len(value["bans"]) > MAX_BANS:
|
||||
raise UpdateError("denylist state exceeds its entry bound")
|
||||
applied = value["applied_sha256"]
|
||||
if not isinstance(applied, str) or (applied and not SHA256_RE.fullmatch(applied)):
|
||||
raise UpdateError("denylist state has an invalid applied digest")
|
||||
bans = {}
|
||||
for address, expires_at in value["bans"].items():
|
||||
canonical = canonical_ip(address)
|
||||
if canonical != address or isinstance(expires_at, bool) or not isinstance(expires_at, int):
|
||||
raise UpdateError("denylist state has a noncanonical entry")
|
||||
if expires_at <= 0 or expires_at > 253402300799:
|
||||
raise UpdateError("denylist state has an invalid expiry")
|
||||
bans[canonical] = expires_at
|
||||
return {"version": STATE_VERSION, "bans": bans, "applied_sha256": applied}
|
||||
|
||||
|
||||
def _encode_state(state):
|
||||
return (json.dumps(state, sort_keys=True, separators=(",", ":")) + "\n").encode("ascii")
|
||||
|
||||
|
||||
def _subprocess_runner(command):
|
||||
environment = {
|
||||
"HOME": "/root",
|
||||
"LANG": "C.UTF-8",
|
||||
"LC_ALL": "C.UTF-8",
|
||||
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
}
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=environment,
|
||||
timeout=45,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
return completed.returncode == 0
|
||||
|
||||
|
||||
class DenylistUpdater:
|
||||
def __init__(
|
||||
self,
|
||||
state_path,
|
||||
snippet_path,
|
||||
project_directory="/opt/truf",
|
||||
env_file="/etc/truf-edge/edge.env",
|
||||
profile=None,
|
||||
runner=None,
|
||||
clock=None,
|
||||
):
|
||||
self.state_path = Path(state_path)
|
||||
self.snippet_path = Path(snippet_path)
|
||||
self.lock_path = self.state_path.with_suffix(self.state_path.suffix + ".lock")
|
||||
if profile is None:
|
||||
try:
|
||||
profile = PROFILE_PATH.read_text(encoding="ascii").strip()
|
||||
except FileNotFoundError:
|
||||
profile = STANDALONE_PROFILE
|
||||
except (OSError, UnicodeError):
|
||||
raise UpdateError("deployment profile is unreadable") from None
|
||||
if profile not in {STANDALONE_PROFILE, SHARED_HOST_PROFILE}:
|
||||
raise UpdateError("deployment profile is unsupported")
|
||||
compose_file = (
|
||||
"compose.shared-host.yaml"
|
||||
if profile == SHARED_HOST_PROFILE else "compose.edge.yaml"
|
||||
)
|
||||
caddyfile = (
|
||||
"/etc/caddy/Caddyfile.shared-host"
|
||||
if profile == SHARED_HOST_PROFILE else "/etc/caddy/Caddyfile"
|
||||
)
|
||||
self.matcher = "client_ip" if profile == SHARED_HOST_PROFILE else "remote_ip"
|
||||
compose = (
|
||||
"docker", "compose", "--ansi", "never", "--env-file", str(env_file),
|
||||
"--project-directory", str(project_directory),
|
||||
"--file", str(Path(project_directory) / "compose.yaml"),
|
||||
"--file", str(Path(project_directory) / compose_file),
|
||||
)
|
||||
self.validate_command = compose + (
|
||||
"exec", "-T", "edge", "caddy", "validate",
|
||||
"--config", caddyfile, "--adapter", "caddyfile",
|
||||
)
|
||||
self.reload_command = compose + (
|
||||
"exec", "-T", "edge", "caddy", "reload",
|
||||
"--config", caddyfile, "--adapter", "caddyfile",
|
||||
"--address", "unix//run/caddy-admin.sock",
|
||||
)
|
||||
self.runner = runner or _subprocess_runner
|
||||
self.clock = clock or time.time
|
||||
|
||||
def _run(self, command, phase):
|
||||
try:
|
||||
succeeded = self.runner(command)
|
||||
except Exception as exc:
|
||||
raise CommandFailure(f"{phase} command failed") from exc
|
||||
if not succeeded:
|
||||
raise CommandFailure(f"{phase} command failed")
|
||||
|
||||
def update(self, operation, address=None):
|
||||
if operation not in {"ban", "unban", "expire", "status"}:
|
||||
raise UpdateError("unsupported operation")
|
||||
canonical = canonical_ip(address) if operation in {"ban", "unban"} else None
|
||||
now = int(self.clock())
|
||||
if now <= 0:
|
||||
raise UpdateError("system clock is invalid")
|
||||
|
||||
with _exclusive_lock(self.lock_path):
|
||||
old_state_content = _read_optional(self.state_path)
|
||||
old_snippet_content = _read_optional(self.snippet_path)
|
||||
state = _load_state(old_state_content)
|
||||
bans = {
|
||||
ip: expires_at for ip, expires_at in state["bans"].items()
|
||||
if expires_at > now
|
||||
}
|
||||
expired = len(state["bans"]) - len(bans)
|
||||
|
||||
if operation == "ban":
|
||||
if canonical not in bans and len(bans) >= MAX_BANS:
|
||||
raise UpdateError("denylist entry bound reached")
|
||||
bans[canonical] = max(bans.get(canonical, 0), now + BAN_SECONDS)
|
||||
elif operation == "unban":
|
||||
bans.pop(canonical, None)
|
||||
|
||||
desired_snippet = render_snippet(bans, self.matcher)
|
||||
desired_digest = _digest(desired_snippet)
|
||||
pending_state = {
|
||||
"version": STATE_VERSION,
|
||||
"bans": bans,
|
||||
"applied_sha256": state["applied_sha256"],
|
||||
}
|
||||
pending_content = _encode_state(pending_state)
|
||||
needs_reload = (
|
||||
old_snippet_content != desired_snippet
|
||||
or state["applied_sha256"] != desired_digest
|
||||
)
|
||||
needs_state_write = old_state_content != pending_content
|
||||
|
||||
if needs_reload:
|
||||
reload_attempted = False
|
||||
try:
|
||||
_atomic_write(self.state_path, pending_content, 0o600)
|
||||
_atomic_write(self.snippet_path, desired_snippet, 0o640)
|
||||
self._run(self.validate_command, "validation")
|
||||
reload_attempted = True
|
||||
self._run(self.reload_command, "reload")
|
||||
pending_state["applied_sha256"] = desired_digest
|
||||
_atomic_write(self.state_path, _encode_state(pending_state), 0o600)
|
||||
except Exception as original:
|
||||
try:
|
||||
_restore(self.state_path, old_state_content, 0o600)
|
||||
_restore(self.snippet_path, old_snippet_content, 0o640)
|
||||
if reload_attempted:
|
||||
self._run(self.validate_command, "rollback validation")
|
||||
self._run(self.reload_command, "rollback reload")
|
||||
except Exception as rollback:
|
||||
raise RollbackFailure("denylist rollback failed") from rollback
|
||||
if isinstance(original, UpdateError):
|
||||
raise
|
||||
raise UpdateError("denylist update failed") from original
|
||||
elif needs_state_write:
|
||||
pending_state["applied_sha256"] = desired_digest
|
||||
_atomic_write(self.state_path, _encode_state(pending_state), 0o600)
|
||||
|
||||
return {
|
||||
"operation": operation,
|
||||
"ip": canonical,
|
||||
"expired": expired,
|
||||
"bans": dict(bans),
|
||||
}
|
||||
|
||||
|
||||
def _emit(result):
|
||||
bans = result["bans"]
|
||||
if result["operation"] == "status":
|
||||
addresses = sorted(
|
||||
bans, key=lambda value: (ipaddress.ip_address(value).version, int(ipaddress.ip_address(value)))
|
||||
)
|
||||
payload = {
|
||||
"active": len(addresses),
|
||||
"bans": [
|
||||
{"ip": address, "expires_at": bans[address]}
|
||||
for address in addresses[:256]
|
||||
],
|
||||
"event": "admin_denylist_status",
|
||||
"truncated": len(addresses) > 256,
|
||||
}
|
||||
else:
|
||||
payload = {
|
||||
"active": len(bans),
|
||||
"event": "admin_denylist_" + result["operation"],
|
||||
"expired": result["expired"],
|
||||
}
|
||||
if result["ip"] is not None:
|
||||
payload["ip"] = result["ip"]
|
||||
print(json.dumps(payload, sort_keys=True, separators=(",", ":")), flush=True)
|
||||
|
||||
|
||||
def parse_args(argv=None):
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||
parser.add_argument("--state-path", default="/var/lib/truf-edge/admin-denylist.json")
|
||||
parser.add_argument("--snippet-path", default="/etc/truf-edge/denylist/admin-denylist.caddy")
|
||||
parser.add_argument("--project-directory", default="/opt/truf")
|
||||
parser.add_argument("--env-file", default="/etc/truf-edge/edge.env")
|
||||
commands = parser.add_subparsers(dest="operation", required=True)
|
||||
for name in ("ban", "unban"):
|
||||
command = commands.add_parser(name, allow_abbrev=False)
|
||||
command.add_argument("ip")
|
||||
commands.add_parser("expire", allow_abbrev=False)
|
||||
commands.add_parser("status", allow_abbrev=False)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = parse_args(argv)
|
||||
updater = DenylistUpdater(
|
||||
args.state_path,
|
||||
args.snippet_path,
|
||||
project_directory=args.project_directory,
|
||||
env_file=args.env_file,
|
||||
)
|
||||
try:
|
||||
result = updater.update(args.operation, getattr(args, "ip", None))
|
||||
except Exception as exc:
|
||||
payload = {
|
||||
"event": "admin_denylist_error",
|
||||
"operation": args.operation,
|
||||
"reason": type(exc).__name__,
|
||||
}
|
||||
print(json.dumps(payload, sort_keys=True, separators=(",", ":")), file=sys.stderr)
|
||||
return 1
|
||||
_emit(result)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user