Initial server source import

This commit is contained in:
sashatrask
2026-09-30 20:30:56 +03:00
commit 170dd941b9
498 changed files with 261563 additions and 0 deletions
+61
View File
@@ -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>'
+194
View File
@@ -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())