merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

View File

@@ -0,0 +1,24 @@
ARG BASE_IMAGE=debian:bookworm-slim
FROM ${BASE_IMAGE}
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
&& rm -rf /var/lib/apt/lists/*
COPY syslog_bridge.py /opt/syslog_bridge.py
COPY instance_seed.py /opt/instance_seed.py
COPY server.py /opt/server.py
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 6379
RUN useradd -r -s /bin/false -d /opt logrelay \
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
&& rm -rf /var/lib/apt/lists/* \
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD kill -0 1 || exit 1
USER logrelay
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,3 @@
#!/bin/bash
set -e
exec python3 /opt/server.py

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""
Per-instance stealth seeding for honeypot service templates.
The whole decoy fleet looks identical to a scanner unless each decky
diverges on the boring details: cluster UUIDs, auth salts, uptime, minor
version strings, etc. This module derives a stable per-instance seed
from NODE_NAME (+ optional INSTANCE_ID) and exposes helpers that return
deterministic-per-decky-but-different-across-the-fleet values.
Connection-time jitter is intentionally NOT seeded — two hits to the same
decky should not replay the same latency curve.
"""
from __future__ import annotations
import asyncio
import hashlib
import os
import random
import time
import uuid
from typing import Sequence, TypeVar
T = TypeVar("T")
_HOSTNAME = (
os.environ.get("NODE_NAME")
or os.environ.get("HOSTNAME")
or "decky"
)
_INSTANCE_ID = os.environ.get("INSTANCE_ID", "")
_SEED_MATERIAL = f"{_HOSTNAME}:{_INSTANCE_ID}".encode()
_SEED_INT = int.from_bytes(hashlib.sha256(_SEED_MATERIAL).digest()[:8], "big")
#: Deterministic RNG seeded per decky — use for *persistent* choices
#: (versions, UUIDs, stored credentials). Never use for timing.
rng = random.Random(_SEED_INT)
#: Process boot time — real uptime elapsed since container start.
_PROCESS_START = time.time()
#: Deterministic per-instance fake "has been up for this long at boot"
#: offset, so every decky pretends to have a different history.
_BOOT_OFFSET = rng.randint(3600, 45 * 86400)
def hostname() -> str:
return _HOSTNAME
def uptime_seconds() -> int:
"""Monotonically increasing, unique per instance."""
return int(_BOOT_OFFSET + (time.time() - _PROCESS_START))
def boot_epoch() -> int:
"""Fake wall-clock boot time for this instance (seconds since epoch)."""
return int(time.time() - uptime_seconds())
def instance_uuid(namespace: str = "") -> str:
"""Deterministic UUID4-looking value for this instance+namespace."""
ns = uuid.UUID("00000000-0000-0000-0000-000000000000")
return str(uuid.uuid5(ns, f"{_HOSTNAME}:{namespace}"))
def instance_hex(nbytes: int, namespace: str = "") -> str:
"""Deterministic hex token of given byte length."""
material = f"{_HOSTNAME}:{namespace}".encode()
digest = hashlib.sha256(material).digest()
while len(digest) < nbytes:
digest += hashlib.sha256(digest).digest()
return digest[:nbytes].hex()
def pick(choices: Sequence[T]) -> T:
"""Deterministic choice from a sequence."""
return rng.choice(list(choices))
def pick_weighted(choices: Sequence[tuple[T, float]]) -> T:
"""Deterministic weighted choice. Input: [(item, weight), ...]."""
total = sum(w for _, w in choices)
r = rng.uniform(0, total)
acc = 0.0
for item, w in choices:
acc += w
if r <= acc:
return item
return choices[-1][0]
def random_bytes(n: int, namespace: str = "") -> bytes:
"""Deterministic per-instance byte string of length n."""
out = bytearray()
i = 0
while len(out) < n:
out.extend(
hashlib.sha256(f"{_HOSTNAME}:{namespace}:{i}".encode()).digest()
)
i += 1
return bytes(out[:n])
def fresh_bytes(n: int) -> bytes:
"""Non-deterministic random bytes — for per-connection nonces/salts."""
return os.urandom(n)
async def jitter(min_ms: int = 5, max_ms: int = 120) -> None:
"""Async response-time jitter. Uses unseeded RNG so timing varies
across connections to the same decky — seeded jitter would leak
predictability."""
await asyncio.sleep(random.uniform(min_ms, max_ms) / 1000.0)
def jitter_sync(min_ms: int = 5, max_ms: int = 120) -> None:
"""Blocking jitter for non-asyncio servers."""
time.sleep(random.uniform(min_ms, max_ms) / 1000.0)

View File

@@ -0,0 +1,333 @@
#!/usr/bin/env python3
"""
Redisserver.
Implements enough of the RESP protocol to respond to AUTH, INFO, CONFIG GET,
KEYS, and arbitrary commands. Logs every command and argument as JSON.
"""
import asyncio
import os
import instance_seed as _seed
from syslog_bridge import (
encode_secret,
forward_syslog,
syslog_line,
write_syslog_file,
)
NODE_NAME = os.environ.get("NODE_NAME", "cache-server")
SERVICE_NAME = "redis"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
PORT = int(os.environ.get("PORT", "6379"))
# Per-instance realistic version pick (weighted toward still-supported lines).
_REDIS_VER = os.environ.get("REDIS_VERSION") or _seed.pick_weighted([
("7.2.4", 2), ("7.2.5", 3), ("7.2.6", 3), ("7.2.7", 2),
("7.0.15", 2), ("7.0.14", 1),
("6.2.14", 2), ("6.2.16", 1),
])
# Kernel line matching plausible Debian/Ubuntu LTS minor ranges.
_REDIS_OS = os.environ.get("REDIS_OS") or _seed.pick([
"Linux 5.15.0-118-generic x86_64",
"Linux 6.1.0-21-amd64 x86_64",
"Linux 5.10.0-30-amd64 x86_64",
"Linux 6.5.0-27-generic x86_64",
])
_RUN_ID = _seed.instance_hex(20, "redis-run")
_PROCESS_ID = _seed.rng.randint(120, 32000)
_TCP_PORT_STR = str(PORT)
# AUTH config: empty REDIS_PASSWORD means "no auth configured" — AUTH returns
# the canonical "Client sent AUTH, but no password is set" error, matching a
# real redis-server with requirepass unset.
_REQUIREPASS = os.environ.get("REDIS_PASSWORD", "")
def _info_block() -> bytes:
uptime = _seed.uptime_seconds()
uptime_days = max(1, uptime // 86400)
# Minimal but plausible subset; real redis INFO has ~150 keys.
text = (
"# Server\r\n"
f"redis_version:{_REDIS_VER}\r\n"
f"redis_git_sha1:00000000\r\n"
f"redis_git_dirty:0\r\n"
f"redis_build_id:{_seed.instance_hex(8, 'redis-build')}\r\n"
"redis_mode:standalone\r\n"
f"os:{_REDIS_OS}\r\n"
"arch_bits:64\r\n"
f"process_id:{_PROCESS_ID}\r\n"
f"run_id:{_RUN_ID}\r\n"
f"tcp_port:{_TCP_PORT_STR}\r\n"
f"uptime_in_seconds:{uptime}\r\n"
f"uptime_in_days:{uptime_days}\r\n"
"hz:10\r\n"
"# Clients\r\n"
"connected_clients:1\r\n"
"maxclients:10000\r\n"
"# Memory\r\n"
f"used_memory:{_seed.rng.randint(800_000, 12_000_000)}\r\n"
"mem_fragmentation_ratio:1.12\r\n"
"# Stats\r\n"
f"total_connections_received:{_seed.rng.randint(50, 9000)}\r\n"
f"total_commands_processed:{_seed.rng.randint(5_000, 2_000_000)}\r\n"
"# Keyspace\r\n"
)
return text.encode()
def _build_fake_store() -> dict[bytes, bytes]:
"""Per-instance plausible cache content. No embedded DECNET-identifying
strings; keys / values shaped like what real apps leave in redis."""
n_sessions = _seed.rng.randint(3, 14)
store: dict[bytes, bytes] = {}
app_slug = _seed.pick(["api", "web", "worker", "shop", "admin", "cms"])
env_slug = _seed.pick(["prod", "stage", "live"])
for i in range(n_sessions):
sid = _seed.instance_hex(16, f"sess-{i}")
uid = _seed.rng.randint(1000, 999_999)
store[f"session:{sid}".encode()] = (
f'{{"uid":{uid},"exp":{int(_seed.boot_epoch()) + 86400 * 7}}}'
).encode()
for i in range(_seed.rng.randint(2, 6)):
store[f"cache:{app_slug}:feed:{i}".encode()] = (
_seed.instance_hex(24, f"feed-{i}").encode()
)
store[f"stats:{app_slug}:{env_slug}:requests".encode()] = (
str(_seed.rng.randint(5_000, 900_000)).encode()
)
return store
_FAKE_STORE = _build_fake_store()
# Config presented via CONFIG GET — realistic subset of a default redis.conf.
_CONFIG = {
"maxmemory": "0",
"maxmemory-policy": "noeviction",
"maxclients": "10000",
"timeout": "0",
"tcp-keepalive": "300",
"databases": "16",
"save": "3600 1 300 100 60 10000",
"appendonly": "no",
"loglevel": "notice",
"dir": "/var/lib/redis",
"bind": "127.0.0.1 -::1",
"protected-mode": "yes",
"supervised": "systemd",
}
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
write_syslog_file(line)
forward_syslog(line, LOG_TARGET)
def _bulk(s: str) -> bytes:
enc = s.encode()
return f"${len(enc)}\r\n".encode() + enc + b"\r\n"
def _err(msg: str) -> bytes:
return f"-ERR {msg}\r\n".encode()
class RESPParser:
"""Incremental RESP array parser — returns list of str tokens or None if incomplete."""
def __init__(self):
self._buf = b""
def feed(self, data: bytes):
self._buf += data
return self._try_parse()
def _try_parse(self):
commands = []
while self._buf:
cmd, consumed = self._parse_one(self._buf)
if cmd is None:
break
commands.append(cmd)
self._buf = self._buf[consumed:]
return commands
def _parse_one(self, buf: bytes):
if not buf:
return None, 0
if buf[0:1] == b"*":
end = buf.find(b"\r\n")
if end == -1:
return None, 0
count = int(buf[1:end])
pos = end + 2
parts = []
for _ in range(count):
if pos >= len(buf):
return None, 0
if buf[pos:pos + 1] != b"$":
return None, 0
end2 = buf.find(b"\r\n", pos)
if end2 == -1:
return None, 0
length = int(buf[pos + 1:end2])
start = end2 + 2
if start + length + 2 > len(buf):
return None, 0
parts.append(buf[start:start + length].decode(errors="replace"))
pos = start + length + 2
return parts, pos
# Inline command
end = buf.find(b"\r\n")
if end == -1:
end = buf.find(b"\n")
if end == -1:
return None, 0
line = buf[:end].decode(errors="replace").strip()
return line.split(), end + (2 if buf[end:end + 2] == b"\r\n" else 1)
def _config_get(pattern: str) -> bytes:
"""Emulate `CONFIG GET <pattern>` — returns alternating key/value bulks."""
import fnmatch
matches = [(k, v) for k, v in _CONFIG.items() if fnmatch.fnmatchcase(k, pattern)]
out = f"*{len(matches) * 2}\r\n".encode()
for k, v in matches:
out += _bulk(k) + _bulk(v)
return out
class RedisProtocol(asyncio.Protocol):
def __init__(self):
self._transport = None
self._peer = None
self._parser = RESPParser()
self._authed = not _REQUIREPASS # auth satisfied iff no password set
def connection_made(self, transport):
self._transport = transport
self._peer = transport.get_extra_info("peername", ("?", 0))
_log("connect", src=self._peer[0], src_port=self._peer[1])
def data_received(self, data):
for cmd in self._parser.feed(data):
self._handle_command(cmd)
def _write(self, payload: bytes) -> None:
"""Writes with per-response jitter. Unseeded so two connections to
the same decky don't get an identical latency fingerprint. Honeypot
throughput targets are low; a few ms of blocking sleep here is fine
and avoids the asyncio-task plumbing the synchronous protocol model
doesn't otherwise need."""
_seed.jitter_sync(2, 40)
if self._transport and not self._transport.is_closing():
self._transport.write(payload)
def _handle_command(self, parts):
if not parts:
return
verb = parts[0].upper()
args = parts[1:]
_log("command", src=self._peer[0], cmd=verb, args=args[:8])
if verb == "AUTH":
# Redis 6+ accepts two-arg AUTH (`AUTH <user> <pw>`) for ACL
# auth; legacy single-arg AUTH is just the password. Capture
# the username when present so attackers brute-forcing ACLs
# leave the same trail SSH/FTP do.
password = args[-1] if args else ""
_user = args[0] if len(args) >= 2 else None
_log("auth", src=self._peer[0],
principal=_user, **encode_secret(password))
if not _REQUIREPASS:
self._write(
_err("Client sent AUTH, but no password is set. "
"Did you mean AUTH <username> <password>?")
)
elif password == _REQUIREPASS:
self._authed = True
self._write(b"+OK\r\n")
else:
self._write(_err("WRONGPASS invalid username-password pair or user is disabled."))
return
if not self._authed:
self._write(_err("NOAUTH Authentication required."))
return
if verb == "INFO":
info = _info_block()
self._write(f"${len(info)}\r\n".encode() + info + b"\r\n")
elif verb == "PING":
self._write(b"+PONG\r\n")
elif verb == "CONFIG":
sub = args[0].upper() if args else ""
if sub == "GET" and len(args) >= 2:
self._write(_config_get(args[1]))
elif sub == "SET":
self._write(b"+OK\r\n")
elif sub == "RESETSTAT":
self._write(b"+OK\r\n")
else:
self._write(_err(
"Unknown CONFIG subcommand or wrong number of arguments for '"
f"{sub.lower() or '?'}'"
))
elif verb == "KEYS":
pattern = args[0] if args else "*"
keys = list(_FAKE_STORE.keys())
if pattern.endswith('*') and pattern != '*':
prefix = pattern[:-1].encode()
keys = [k for k in keys if k.startswith(prefix)]
elif pattern != '*':
pat = pattern.encode()
keys = [k for k in keys if k == pat]
resp = f"*{len(keys)}\r\n".encode() + b"".join(_bulk(k.decode()) for k in keys)
self._write(resp)
elif verb == "GET":
key = args[0].encode() if args else b""
if key in _FAKE_STORE:
self._write(_bulk(_FAKE_STORE[key].decode()))
else:
self._write(b"$-1\r\n")
elif verb == "SCAN":
keys = list(_FAKE_STORE.keys())
resp = b"*2\r\n$1\r\n0\r\n" + f"*{len(keys)}\r\n".encode() + b"".join(_bulk(k.decode()) for k in keys)
self._write(resp)
elif verb == "TYPE":
self._write(b"+string\r\n")
elif verb == "TTL":
self._write(b":-1\r\n")
elif verb == "DBSIZE":
self._write(f":{len(_FAKE_STORE)}\r\n".encode())
elif verb == "COMMAND":
self._write(b"*0\r\n")
elif verb == "CLIENT":
self._write(b"+OK\r\n")
elif verb == "SELECT":
self._write(b"+OK\r\n")
elif verb == "QUIT":
self._write(b"+OK\r\n")
if self._transport:
self._transport.close()
else:
self._write(_err(f"unknown command '{verb.lower()}'"))
def connection_lost(self, exc):
_log("disconnect", src=self._peer[0] if self._peer else "?")
async def main():
_log("startup", msg=f"Redis server starting as {NODE_NAME}")
loop = asyncio.get_running_loop()
server = await loop.create_server(RedisProtocol, "0.0.0.0", PORT) # nosec B104
async with server:
await server.serve_forever()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""
Shared RFC 5424 syslog helper used by service containers.
Services call syslog_line() to format an RFC 5424 message, then
write_syslog_file() to emit it to stdout — the container runtime
captures it, and the host-side collector streams it into the log file.
RFC 5424 structure:
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
Facility: local0 (16). SD element ID uses PEN 55555.
"""
import base64
import re
from datetime import datetime, timezone
from typing import Any, Optional
# ─── Constants ────────────────────────────────────────────────────────────────
_FACILITY_LOCAL0 = 16
_SD_ID = "relay@55555"
_NILVALUE = "-"
SEVERITY_EMERG = 0
SEVERITY_ALERT = 1
SEVERITY_CRIT = 2
SEVERITY_ERROR = 3
SEVERITY_WARNING = 4
SEVERITY_NOTICE = 5
SEVERITY_INFO = 6
SEVERITY_DEBUG = 7
_MAX_HOSTNAME = 255
_MAX_APPNAME = 48
_MAX_MSGID = 32
# ─── Formatter ────────────────────────────────────────────────────────────────
def _sd_escape(value: str) -> str:
"""Escape SD-PARAM-VALUE per RFC 5424 §6.3.3."""
return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
def _sd_element(fields: dict[str, Any]) -> str:
if not fields:
return _NILVALUE
params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items())
return f"[{_SD_ID} {params}]"
def syslog_line(
service: str,
hostname: str,
event_type: str,
severity: int = SEVERITY_INFO,
timestamp: datetime | None = None,
msg: str | None = None,
**fields: Any,
) -> str:
"""
Return a single RFC 5424-compliant syslog line (no trailing newline).
Args:
service: APP-NAME (e.g. "http", "mysql")
hostname: HOSTNAME (node name)
event_type: MSGID (e.g. "request", "login_attempt")
severity: Syslog severity integer (default: INFO=6)
timestamp: UTC datetime; defaults to now
msg: Optional free-text MSG
**fields: Encoded as structured data params
"""
pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>"
ts = (timestamp or datetime.now(timezone.utc)).isoformat()
host = (hostname or _NILVALUE)[:_MAX_HOSTNAME]
appname = (service or _NILVALUE)[:_MAX_APPNAME]
msgid = (event_type or _NILVALUE)[:_MAX_MSGID]
sd = _sd_element(fields)
message = f" {msg}" if msg else ""
return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}"
def encode_secret(secret: str) -> dict[str, str]:
"""Standardized credential-secret encoding for the universal SD-block shape.
Returns ``{'secret_printable': ..., 'secret_b64': ...}`` ready to spread
into a :func:`syslog_line` / ``_log`` call::
_log("auth_attempt", principal=user, **encode_secret(password))
``secret_printable`` mirrors auth-helper.c's sd_escape: bytes outside
``[0x20, 0x7f)`` collapse to ``'?'`` so the field is always parser-safe
RFC 5424 ASCII. ``secret_b64`` preserves the *original* utf-8 bytes —
NUL/0xff/control/non-utf8 sequences all survive losslessly, useful as
a fingerprinting signal even when the printable form sanitizes them.
The decnet web ingester's native-shape branch keys off ``secret_b64``
being present, so any service emitter calling this helper lands its
cred attempt directly in the :class:`Credential` table.
"""
raw = secret.encode("utf-8", errors="replace")
printable = "".join(chr(b) if 0x20 <= b < 0x7f else "?" for b in raw)
return {
"secret_printable": printable,
"secret_b64": base64.b64encode(raw).decode("ascii"),
}
_DIGEST_PARAM_RE = re.compile(r'(\w+)\s*=\s*"([^"]*)"|(\w+)\s*=\s*([^,\s]+)')
def classify_authorization(header_value: Optional[str]) -> Optional[dict[str, Any]]:
"""Parse an HTTP Authorization header value into Credential SD fields.
Returns a dict with the universal cred shape ready to spread into a
``_log(...)`` call::
auth = request.headers.get("Authorization")
cred = classify_authorization(auth)
if cred:
_log("auth_attempt", **cred)
Recognised schemes:
* Basic — base64(user:pw); decoded → ``principal=user`` +
``secret_kind="plaintext"`` + ``encode_secret(pw)``.
* Bearer / Token — opaque token; ``principal=None`` +
``secret_kind="http_bearer"`` + ``encode_secret(token)``.
* Digest — ``principal=username`` from header +
``secret_kind="http_digest_md5"`` + ``encode_secret(response)``.
Returns ``None`` for anything unrecognized (AWS4-HMAC-SHA256, NTLM,
Negotiate, …) — callers can still log the raw header value in the
ambient SD-block; we just don't know how to extract a hashable
secret from it.
"""
if not header_value or not isinstance(header_value, str):
return None
parts = header_value.strip().split(None, 1)
if len(parts) < 2:
return None
scheme, rest = parts[0].lower(), parts[1].strip()
if scheme == "basic":
try:
decoded = base64.b64decode(rest, validate=True).decode("utf-8", errors="replace")
except (ValueError, base64.binascii.Error):
return None
if ":" not in decoded:
return None
user, _, pw = decoded.partition(":")
return {
"principal": user,
"secret_kind": "plaintext",
**encode_secret(pw),
}
if scheme in ("bearer", "token"):
return {
"principal": None,
"secret_kind": "http_bearer",
**encode_secret(rest),
}
if scheme == "digest":
params: dict[str, str] = {}
for m in _DIGEST_PARAM_RE.finditer(rest):
k = m.group(1) or m.group(3)
v = m.group(2) if m.group(2) is not None else m.group(4)
if k:
params[k.lower()] = v
response = params.get("response")
if not response:
return None
return {
"principal": params.get("username"),
"secret_kind": "http_digest_md5",
**encode_secret(response),
}
return None
_FORM_PRINCIPAL_KEYS = (
"username", "user", "email", "login", "userid", "account",
"log", # wp-login.php
"user_login", # WordPress alt
"uname", # phpMyAdmin
"pma_username",
)
_FORM_SECRET_KEYS = (
"password", "pass", "pwd", "passwd", "passwort", "mot_de_passe",
"user_password", # WordPress alt
"pma_password", # phpMyAdmin
)
def extract_form_credentials(
body: Optional[str],
content_type: Optional[str],
) -> Optional[dict[str, Any]]:
"""Parse an `application/x-www-form-urlencoded` body for credentials.
Returns the universal cred SD shape ready to spread into a
``_log(...)`` call when both a principal-shaped key and a secret-
shaped key are present in the body. Otherwise returns ``None``.
Field-name detection is case-insensitive and covers the most common
login-form variants (WordPress wp-login.php, phpMyAdmin, Joomla,
etc.). Add more entries to ``_FORM_PRINCIPAL_KEYS`` /
``_FORM_SECRET_KEYS`` as new templates surface them.
"""
if not body or not isinstance(content_type, str):
return None
if not content_type.lower().startswith("application/x-www-form-urlencoded"):
return None
fields: dict[str, str] = {}
for pair in body.split("&"):
if "=" not in pair:
continue
k, _, v = pair.partition("=")
# urllib decode without importing urllib at module scope (the
# template emitters are import-cost-sensitive). Inline the
# tiny percent-decode + plus-decode.
try:
from urllib.parse import unquote_plus
key = unquote_plus(k).lower()
val = unquote_plus(v)
except Exception:
continue
# First-wins so duplicate-key forms don't get clobbered.
fields.setdefault(key, val)
principal: Optional[str] = None
for k in _FORM_PRINCIPAL_KEYS:
if k in fields:
principal = fields[k]
break
secret: Optional[str] = None
for k in _FORM_SECRET_KEYS:
if k in fields:
secret = fields[k]
break
if secret is None:
return None
return {
"principal": principal,
"secret_kind": "plaintext",
**encode_secret(secret),
}
def write_syslog_file(line: str) -> None:
"""Emit a syslog line to stdout for container log capture."""
print(line, flush=True)
def forward_syslog(line: str, log_target: str) -> None:
"""No-op stub. TCP forwarding is handled by rsyslog, not by service containers."""
pass