merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
157
decnet/templates/ssh/Dockerfile
Normal file
157
decnet/templates/ssh/Dockerfile
Normal file
@@ -0,0 +1,157 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
|
||||
# ── Stage 1: build the static auth-helper credential-capture binary ──────────
|
||||
# Compiled against musl so the resulting binary is fully static — runs on
|
||||
# any glibc/musl Linux without a libc version match. Stripped at link
|
||||
# time via -s so `file /usr/sbin/auth-helper` reports a generic ELF.
|
||||
FROM debian:bookworm-slim AS auth-helper-build
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends musl-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY auth-helper/auth-helper.c /tmp/auth-helper.c
|
||||
RUN musl-gcc -static -O2 -s -Wall -Wextra \
|
||||
-o /auth-helper /tmp/auth-helper.c
|
||||
|
||||
# ── Stage 2: the actual SSH decky image ──────────────────────────────────────
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openssh-server \
|
||||
sudo \
|
||||
rsyslog \
|
||||
curl \
|
||||
wget \
|
||||
vim \
|
||||
nano \
|
||||
net-tools \
|
||||
procps \
|
||||
htop \
|
||||
git \
|
||||
inotify-tools \
|
||||
psmisc \
|
||||
iproute2 \
|
||||
iputils-ping \
|
||||
ca-certificates \
|
||||
nmap \
|
||||
jq \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p /var/run/sshd /root/.ssh /var/log/journal /var/lib/systemd/coredump \
|
||||
&& chmod 700 /var/lib/systemd/coredump
|
||||
|
||||
# sshd_config: allow root + password auth; VERBOSE so session lines carry
|
||||
# client IP + session PID (needed for file-capture attribution).
|
||||
RUN sed -i \
|
||||
-e 's|^#\?PermitRootLogin.*|PermitRootLogin yes|' \
|
||||
-e 's|^#\?PasswordAuthentication.*|PasswordAuthentication yes|' \
|
||||
-e 's|^#\?ChallengeResponseAuthentication.*|ChallengeResponseAuthentication no|' \
|
||||
-e 's|^#\?LogLevel.*|LogLevel VERBOSE|' \
|
||||
/etc/ssh/sshd_config
|
||||
|
||||
# rsyslog: forward auth.* and user.* to PID 1's stdout in RFC 5424 format.
|
||||
# /proc/1/fd/1 is the container-stdout fd Docker attached — writing there
|
||||
# surfaces lines in `docker logs` without needing a named pipe + relay cat
|
||||
# (which would be readable AND writable by any root-in-container process).
|
||||
#
|
||||
# Filter: drop sshd's native chatter ("Failed password", "Connection from",
|
||||
# "Connection closed by …") and the pam_unix lines emitted from sshd's PAM
|
||||
# stack (programname is inherited from the calling process, so pam_unix(sshd:*)
|
||||
# lines arrive as programname=sshd). These add no signal — our auth-helper
|
||||
# writes structured login_attempt events directly to /proc/1/fd/1 and
|
||||
# capture.sh emits sessions via `logger -t systemd-journal`. sudo / login /
|
||||
# su pam_unix lines are NOT dropped (different programname), so privilege
|
||||
# escalation telemetry still flows.
|
||||
RUN printf '%s\n' \
|
||||
'# auth + user events → container stdout as RFC 5424' \
|
||||
'$template RFC5424fmt,"<%PRI%>1 %TIMESTAMP:::date-rfc3339% %HOSTNAME% %APP-NAME% %PROCID% %MSGID% %STRUCTURED-DATA% %msg%\n"' \
|
||||
':programname, isequal, "sshd" stop' \
|
||||
'auth,authpriv.* /proc/1/fd/1;RFC5424fmt' \
|
||||
'user.* /proc/1/fd/1;RFC5424fmt' \
|
||||
> /etc/rsyslog.d/50-journal-forward.conf
|
||||
|
||||
# Silence default catch-all rules so we own auth/user routing exclusively.
|
||||
# Also disable rsyslog's privilege drop: PID 1's stdout (/proc/1/fd/1) is
|
||||
# owned by root, so a syslog-user rsyslogd gets EACCES and silently drops
|
||||
# every auth/user line (bash CMD events + file_captured emissions).
|
||||
RUN sed -i \
|
||||
-e 's|^\(\*\.\*;auth,authpriv\.none\)|#\1|' \
|
||||
-e 's|^auth,authpriv\.\*|#auth,authpriv.*|' \
|
||||
-e 's|^\$PrivDropToUser|#$PrivDropToUser|' \
|
||||
-e 's|^\$PrivDropToGroup|#$PrivDropToGroup|' \
|
||||
/etc/rsyslog.conf
|
||||
|
||||
# auth-helper: drop the static binary into /usr/sbin and wire pam_exec
|
||||
# into the sshd PAM stack so every password attempt (success or fail) is
|
||||
# captured before pam_unix runs. `optional` so a malfunctioning helper
|
||||
# never blocks auth — fail-open is correct: missed creds are recoverable,
|
||||
# a borked honeypot is not. expose_authtok writes the password to the
|
||||
# helper's stdin, NUL-terminated.
|
||||
COPY --from=auth-helper-build /auth-helper /usr/sbin/auth-helper
|
||||
RUN chmod 755 /usr/sbin/auth-helper && \
|
||||
sed -i '1i auth optional pam_exec.so expose_authtok stdout /usr/sbin/auth-helper' \
|
||||
/etc/pam.d/sshd
|
||||
|
||||
# Sudo: log to syslog (auth facility) AND a local file with full I/O capture
|
||||
RUN echo 'Defaults logfile="/var/log/sudo.log"' >> /etc/sudoers && \
|
||||
echo 'Defaults syslog=auth' >> /etc/sudoers && \
|
||||
echo 'Defaults log_input,log_output' >> /etc/sudoers
|
||||
|
||||
# Lived-in environment: motd, shell aliases, fake project files
|
||||
RUN echo "Ubuntu 22.04.3 LTS" > /etc/issue.net && \
|
||||
echo "Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-88-generic x86_64)" > /etc/motd && \
|
||||
echo "" >> /etc/motd && \
|
||||
echo " * Documentation: https://help.ubuntu.com" >> /etc/motd && \
|
||||
echo " * Management: https://landscape.canonical.com" >> /etc/motd && \
|
||||
echo " * Support: https://ubuntu.com/advantage" >> /etc/motd
|
||||
|
||||
RUN echo 'alias ll="ls -alF"' >> /root/.bashrc && \
|
||||
echo 'alias la="ls -A"' >> /root/.bashrc && \
|
||||
echo 'alias l="ls -CF"' >> /root/.bashrc && \
|
||||
echo 'export HISTSIZE=1000' >> /root/.bashrc && \
|
||||
echo 'export HISTFILESIZE=2000' >> /root/.bashrc && \
|
||||
echo 'PROMPT_COMMAND='"'"'logger -p user.info -t bash "CMD uid=$UID user=$USER src=${SSH_CLIENT%% *} pwd=$PWD cmd=$(history 1 | sed "s/^ *[0-9]* *//")";'"'" >> /root/.bashrc
|
||||
|
||||
# Fake project files to look lived-in
|
||||
RUN mkdir -p /root/projects /root/backups /var/www/html && \
|
||||
printf '# TODO: migrate DB to new server\n# check cron jobs\n# update SSL cert\n' > /root/notes.txt && \
|
||||
printf 'DB_HOST=10.0.0.5\nDB_USER=admin\nDB_PASS=changeme123\nDB_NAME=prod_db\n' > /root/projects/.env && \
|
||||
printf '[Unit]\nDescription=App Server\n[Service]\nExecStart=/usr/bin/python3 /opt/app/server.py\n' > /root/projects/app.service
|
||||
|
||||
# Stage all capture sources in a scratch dir. Nothing here survives the layer:
|
||||
# _build_stealth.py packs syslog_bridge.py + emit_capture.py + capture.sh into
|
||||
# XOR+gzip+base64 blobs embedded directly in /entrypoint.sh, and the whole
|
||||
# /tmp/build tree is wiped at the end of the RUN — so the final image has no
|
||||
# `.py` file under /opt and no `journal-relay` script under /usr/libexec/udev.
|
||||
COPY entrypoint.sh capture.sh syslog_bridge.py emit_capture.py \
|
||||
argv_zap.c _build_stealth.py /tmp/build/
|
||||
COPY sessrec/ /tmp/build/sessrec/
|
||||
|
||||
# argv_zap is compiled into a shared object disguised as a multiarch
|
||||
# udev-companion library (sits next to real libudev.so.1). sessrec is built
|
||||
# as /usr/libexec/login-session, installed as root's login shell so every
|
||||
# interactive SSH session is pty-recorded. gcc is installed only for this
|
||||
# build step and purged in the same layer.
|
||||
RUN set -eu \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gcc libc6-dev make \
|
||||
&& mkdir -p /usr/lib/x86_64-linux-gnu /usr/libexec/udev /usr/libexec \
|
||||
&& gcc -O2 -fPIC -shared \
|
||||
-o /usr/lib/x86_64-linux-gnu/libudev-shared.so.1 \
|
||||
/tmp/build/argv_zap.c -ldl \
|
||||
&& make -C /tmp/build/sessrec install PREFIX=/usr/libexec \
|
||||
&& grep -q '^/usr/libexec/login-session$' /etc/shells \
|
||||
|| echo '/usr/libexec/login-session' >> /etc/shells \
|
||||
&& sed -i 's|^root:\([^:]*\):\([^:]*\):\([^:]*\):\([^:]*\):\([^:]*\):.*$|root:\1:\2:\3:\4:\5:/usr/libexec/login-session|' /etc/passwd \
|
||||
&& apt-get purge -y gcc libc6-dev make \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& ln -sf /usr/bin/inotifywait /usr/libexec/udev/kmsg-watch \
|
||||
&& python3 /tmp/build/_build_stealth.py \
|
||||
&& rm -rf /tmp/build
|
||||
|
||||
EXPOSE 22
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
89
decnet/templates/ssh/_build_stealth.py
Normal file
89
decnet/templates/ssh/_build_stealth.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build-time helper: merge capture Python sources, XOR+gzip+base64 pack them
|
||||
and the capture.sh loop, and render the final /entrypoint.sh from its
|
||||
templated form.
|
||||
|
||||
Runs inside the Docker build. Reads from /tmp/build/, writes /entrypoint.sh.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BUILD = Path("/tmp/build")
|
||||
|
||||
|
||||
def _merge_python() -> str:
|
||||
bridge = (BUILD / "syslog_bridge.py").read_text()
|
||||
emit = (BUILD / "emit_capture.py").read_text()
|
||||
|
||||
def _clean(src: str) -> tuple[list[str], list[str]]:
|
||||
"""Return (future_imports, other_lines) with noise stripped."""
|
||||
futures: list[str] = []
|
||||
rest: list[str] = []
|
||||
for line in src.splitlines():
|
||||
ls = line.lstrip()
|
||||
if ls.startswith("from __future__"):
|
||||
futures.append(line)
|
||||
elif ls.startswith("sys.path.insert") or ls.startswith("from syslog_bridge"):
|
||||
continue
|
||||
else:
|
||||
rest.append(line)
|
||||
return futures, rest
|
||||
|
||||
b_fut, b_rest = _clean(bridge)
|
||||
e_fut, e_rest = _clean(emit)
|
||||
|
||||
# Deduplicate future imports and hoist to the very top.
|
||||
seen: set[str] = set()
|
||||
futures: list[str] = []
|
||||
for line in (*b_fut, *e_fut):
|
||||
stripped = line.strip()
|
||||
if stripped not in seen:
|
||||
seen.add(stripped)
|
||||
futures.append(line)
|
||||
|
||||
header = "\n".join(futures)
|
||||
body = "\n".join(b_rest) + "\n\n" + "\n".join(e_rest)
|
||||
return (header + "\n" if header else "") + body
|
||||
|
||||
|
||||
def _pack(text: str, key: int) -> str:
|
||||
gz = gzip.compress(text.encode("utf-8"))
|
||||
xored = bytes(b ^ key for b in gz)
|
||||
return base64.b64encode(xored).decode("ascii")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
key = random.SystemRandom().randint(1, 255)
|
||||
|
||||
merged_py = _merge_python()
|
||||
capture_sh = (BUILD / "capture.sh").read_text()
|
||||
|
||||
emit_b64 = _pack(merged_py, key)
|
||||
relay_b64 = _pack(capture_sh, key)
|
||||
|
||||
tpl = (BUILD / "entrypoint.sh").read_text()
|
||||
rendered = (
|
||||
tpl.replace("__STEALTH_KEY__", str(key))
|
||||
.replace("__EMIT_CAPTURE_B64__", emit_b64)
|
||||
.replace("__JOURNAL_RELAY_B64__", relay_b64)
|
||||
)
|
||||
|
||||
for marker in ("__STEALTH_KEY__", "__EMIT_CAPTURE_B64__", "__JOURNAL_RELAY_B64__"):
|
||||
if marker in rendered:
|
||||
print(f"build: placeholder {marker} still present after render", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
Path("/entrypoint.sh").write_text(rendered)
|
||||
Path("/entrypoint.sh").chmod(0o755)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
65
decnet/templates/ssh/argv_zap.c
Normal file
65
decnet/templates/ssh/argv_zap.c
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* argv_zap.so — LD_PRELOAD shim that blanks argv[1..] from /proc/PID/cmdline
|
||||
* after the target binary has parsed its arguments.
|
||||
*
|
||||
* Rationale: exec -a can rewrite argv[0], but the remaining args (paths,
|
||||
* flags) remain visible via `ps aux`. By hooking __libc_start_main we can
|
||||
* copy argv into heap-backed storage, hand that to the real main, then
|
||||
* zero the stack-resident argv region so the kernel's cmdline reader
|
||||
* returns just argv[0].
|
||||
*
|
||||
* Usage:
|
||||
* gcc -O2 -fPIC -shared -o argv_zap.so argv_zap.c -ldl
|
||||
* ARGV_ZAP_COMM=kmsg-watch LD_PRELOAD=/path/argv_zap.so \
|
||||
* exec -a "kmsg-watch" inotifywait …
|
||||
*/
|
||||
|
||||
#define _GNU_SOURCE
|
||||
#include <dlfcn.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/prctl.h>
|
||||
|
||||
typedef int (*main_t)(int, char **, char **);
|
||||
typedef int (*libc_start_main_t)(main_t, int, char **,
|
||||
void (*)(void), void (*)(void),
|
||||
void (*)(void), void *);
|
||||
|
||||
static main_t real_main;
|
||||
|
||||
static int wrapped_main(int argc, char **argv, char **envp) {
|
||||
/* Heap-copy argv so the target keeps its arguments. */
|
||||
char **heap_argv = (char **)calloc(argc + 1, sizeof(char *));
|
||||
if (heap_argv) {
|
||||
for (int i = 0; i < argc; i++) {
|
||||
heap_argv[i] = strdup(argv[i] ? argv[i] : "");
|
||||
}
|
||||
}
|
||||
|
||||
/* Zero the contiguous argv[1..] region (argv[0] stays for ps). */
|
||||
if (argc > 1 && argv[1] && argv[argc - 1]) {
|
||||
char *start = argv[1];
|
||||
char *end = argv[argc - 1] + strlen(argv[argc - 1]);
|
||||
if (end > start) memset(start, 0, (size_t)(end - start));
|
||||
}
|
||||
|
||||
/* Optional comm rename so /proc/self/comm mirrors the argv[0] disguise.
|
||||
* Read from ARGV_ZAP_COMM so different callers can pick their own name
|
||||
* (kmsg-watch for inotifywait, journal-relay for the watcher bash, …).
|
||||
* Unset afterwards so children don't accidentally inherit the override. */
|
||||
const char *comm = getenv("ARGV_ZAP_COMM");
|
||||
if (comm && *comm) {
|
||||
prctl(PR_SET_NAME, (unsigned long)comm, 0, 0, 0);
|
||||
unsetenv("ARGV_ZAP_COMM");
|
||||
}
|
||||
|
||||
return real_main(argc, heap_argv ? heap_argv : argv, envp);
|
||||
}
|
||||
|
||||
int __libc_start_main(main_t main_fn, int argc, char **argv,
|
||||
void (*init)(void), void (*fini)(void),
|
||||
void (*rtld_fini)(void), void *stack_end) {
|
||||
real_main = main_fn;
|
||||
libc_start_main_t real = (libc_start_main_t)dlsym(RTLD_NEXT, "__libc_start_main");
|
||||
return real(wrapped_main, argc, argv, init, fini, rtld_fini, stack_end);
|
||||
}
|
||||
190
decnet/templates/ssh/auth-helper/auth-helper.c
Normal file
190
decnet/templates/ssh/auth-helper/auth-helper.c
Normal file
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* auth-helper — RFC 5424 cred-capture helper invoked via pam_exec.so.
|
||||
*
|
||||
* Wired into /etc/pam.d/sshd as:
|
||||
* auth optional pam_exec.so expose_authtok stdout /usr/sbin/auth-helper
|
||||
*
|
||||
* Behaviour:
|
||||
* - Reads $PAM_USER and $PAM_RHOST from environ (set by pam_exec).
|
||||
* - Reads PAM_AUTHTOK from stdin (NUL-terminated, written by pam_exec
|
||||
* when invoked with `expose_authtok`).
|
||||
* - Emits a single RFC 5424 line on /proc/1/fd/1 in the same shape as
|
||||
* templates/syslog_bridge.py:syslog_line() — facility local0, PEN
|
||||
* 55555, MSGID `auth_attempt` (matches FTP's existing event type so
|
||||
* the parser + dashboard pick it up with zero changes).
|
||||
*
|
||||
* SD-block carries the standardized credential shape (matches
|
||||
* decnet/web/db/models/logs.py:Credential). Universal keys consumed
|
||||
* directly by the ingester's native-shape branch:
|
||||
* principal the human-meaningful identity the attacker sent
|
||||
* (username for SSH/Telnet; would be a domain for
|
||||
* SMTP, a DN for LDAP, etc.)
|
||||
* secret_printable RFC 5424-escaped ASCII-printable, '?' for non-
|
||||
* printables. Best-effort display form; may be
|
||||
* lossy on non-UTF8 bytes.
|
||||
* secret_b64 base64 of the exact PAM_AUTHTOK bytes. Lossless.
|
||||
* Preserves NUL/0xff/control bytes that the plain
|
||||
* field would silently drop — useful fingerprinting
|
||||
* signal that survives display sanitization.
|
||||
*
|
||||
* `username` rides alongside as a service-specific identity field for
|
||||
* SSH/Telnet (mirrors `principal`); future emitters (SMTP, LDAP, …)
|
||||
* drop `username` in favor of their service-native identity field.
|
||||
*
|
||||
* Fail-open: every error path silently exits 0. The PAM line is `optional`
|
||||
* so a malfunctioning helper must never break sshd auth.
|
||||
*
|
||||
* PII discipline: the password value is attacker-supplied bytes. Decky
|
||||
* services are not for admin SSH; throwaway creds (root:admin) are the
|
||||
* convention. Limitations tracked in development/DEBT.md (DEBT-038).
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define MAX_USER 256
|
||||
#define MAX_HOST 256
|
||||
#define MAX_PW 1024
|
||||
#define LINE_BUF 8192
|
||||
|
||||
static const char B64[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/* Standard base64 with '=' padding. NUL-terminates *out*. Returns bytes
|
||||
* written (excluding the NUL). On overflow returns 0 and NUL-terminates. */
|
||||
static size_t b64_encode(const unsigned char *in, size_t inlen,
|
||||
char *out, size_t outcap) {
|
||||
size_t i = 0, o = 0;
|
||||
while (i + 3 <= inlen) {
|
||||
if (o + 4 >= outcap) { out[0] = '\0'; return 0; }
|
||||
unsigned x = ((unsigned)in[i] << 16) |
|
||||
((unsigned)in[i+1] << 8) |
|
||||
(unsigned)in[i+2];
|
||||
out[o++] = B64[(x >> 18) & 0x3f];
|
||||
out[o++] = B64[(x >> 12) & 0x3f];
|
||||
out[o++] = B64[(x >> 6) & 0x3f];
|
||||
out[o++] = B64[ x & 0x3f];
|
||||
i += 3;
|
||||
}
|
||||
if (i < inlen) {
|
||||
if (o + 4 >= outcap) { out[0] = '\0'; return 0; }
|
||||
unsigned x = (unsigned)in[i] << 16;
|
||||
if (i + 1 < inlen) x |= (unsigned)in[i+1] << 8;
|
||||
out[o++] = B64[(x >> 18) & 0x3f];
|
||||
out[o++] = B64[(x >> 12) & 0x3f];
|
||||
out[o++] = (i + 1 < inlen) ? B64[(x >> 6) & 0x3f] : '=';
|
||||
out[o++] = '=';
|
||||
}
|
||||
out[o] = '\0';
|
||||
return o;
|
||||
}
|
||||
|
||||
/* RFC 5424 §6.3.3: in SD-PARAM-VALUE, escape \\ → \\\\, " → \", ] → \].
|
||||
* Non-printables become '?' so the line stays parser-safe. */
|
||||
static size_t sd_escape(const unsigned char *in, size_t inlen,
|
||||
char *out, size_t outcap) {
|
||||
size_t o = 0;
|
||||
for (size_t i = 0; i < inlen; i++) {
|
||||
unsigned char c = in[i];
|
||||
if (c == '\\' || c == '"' || c == ']') {
|
||||
if (o + 3 >= outcap) break;
|
||||
out[o++] = '\\';
|
||||
out[o++] = c;
|
||||
} else if (c >= 0x20 && c < 0x7f) {
|
||||
if (o + 2 >= outcap) break;
|
||||
out[o++] = c;
|
||||
} else {
|
||||
if (o + 2 >= outcap) break;
|
||||
out[o++] = '?';
|
||||
}
|
||||
}
|
||||
out[o] = '\0';
|
||||
return o;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
const char *user = getenv("PAM_USER");
|
||||
const char *rhost = getenv("PAM_RHOST");
|
||||
if (!user) user = "";
|
||||
if (!rhost) rhost = "";
|
||||
|
||||
/* Read password until NUL (pam_exec's expose_authtok contract) or EOF. */
|
||||
unsigned char pw_raw[MAX_PW];
|
||||
size_t pw_len = 0;
|
||||
while (pw_len < sizeof(pw_raw)) {
|
||||
ssize_t n = read(0, pw_raw + pw_len, sizeof(pw_raw) - pw_len);
|
||||
if (n <= 0) break;
|
||||
for (ssize_t i = 0; i < n; i++) {
|
||||
if (pw_raw[pw_len + i] == 0) {
|
||||
pw_len += (size_t)i;
|
||||
goto pw_done;
|
||||
}
|
||||
}
|
||||
pw_len += (size_t)n;
|
||||
}
|
||||
pw_done:;
|
||||
|
||||
/* Timestamp: YYYY-MM-DDThh:mm:ss.uuuuuu+00:00 — matches the shape
|
||||
* datetime.now(timezone.utc).isoformat() emits in syslog_bridge.py. */
|
||||
struct timespec ts;
|
||||
if (clock_gettime(CLOCK_REALTIME, &ts) != 0) return 0;
|
||||
struct tm tm;
|
||||
if (gmtime_r(&ts.tv_sec, &tm) == NULL) return 0;
|
||||
char tsbuf[40];
|
||||
snprintf(tsbuf, sizeof(tsbuf),
|
||||
"%04d-%02d-%02dT%02d:%02d:%02d.%06ld+00:00",
|
||||
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
|
||||
tm.tm_hour, tm.tm_min, tm.tm_sec,
|
||||
(long)(ts.tv_nsec / 1000));
|
||||
|
||||
char host[MAX_HOST];
|
||||
if (gethostname(host, sizeof(host) - 1) != 0) {
|
||||
host[0] = '-'; host[1] = '\0';
|
||||
} else {
|
||||
host[sizeof(host) - 1] = '\0';
|
||||
}
|
||||
|
||||
/* Escape / encode the dynamic fields. Buffers sized 2x source to
|
||||
* survive worst-case escape expansion. */
|
||||
char user_esc [MAX_USER * 2];
|
||||
char rhost_esc[MAX_HOST * 2];
|
||||
char pw_esc [MAX_PW * 2];
|
||||
char pw_b64 [MAX_PW * 2];
|
||||
|
||||
sd_escape((const unsigned char *)user, strlen(user), user_esc, sizeof(user_esc));
|
||||
sd_escape((const unsigned char *)rhost, strlen(rhost), rhost_esc, sizeof(rhost_esc));
|
||||
sd_escape(pw_raw, pw_len, pw_esc, sizeof(pw_esc));
|
||||
b64_encode(pw_raw, pw_len, pw_b64, sizeof(pw_b64));
|
||||
|
||||
/* Priority: facility=local0(16), severity=INFO(6) → <16*8+6> = <134>.
|
||||
* Matches the syslog_bridge.py default exactly.
|
||||
*
|
||||
* SD-block keys match the Credential storage model: principal +
|
||||
* secret_printable + secret_b64 are the universal keys the ingester
|
||||
* keys off; username is emitted alongside principal so existing
|
||||
* dashboards that read SSH/Telnet `username=` keep working until
|
||||
* the cred-reuse UI lands. */
|
||||
char line[LINE_BUF];
|
||||
int n = snprintf(line, sizeof(line),
|
||||
"<134>1 %s %s auth-helper - auth_attempt "
|
||||
"[relay@55555 username=\"%s\" principal=\"%s\" "
|
||||
"secret_printable=\"%s\" secret_b64=\"%s\" src_ip=\"%s\"]\n",
|
||||
tsbuf, host, user_esc, user_esc, pw_esc, pw_b64, rhost_esc);
|
||||
if (n <= 0 || (size_t)n >= sizeof(line)) return 0;
|
||||
|
||||
/* /proc/1/fd/1 is the entrypoint's stdout — the fd Docker captures
|
||||
* for `docker logs`. Same channel rsyslog forwards auth.* into via
|
||||
* the existing template; we bypass rsyslog entirely so behaviour is
|
||||
* deterministic across rsyslog config drift. */
|
||||
int fd = open("/proc/1/fd/1", O_WRONLY | O_APPEND);
|
||||
if (fd < 0) return 0;
|
||||
ssize_t w = write(fd, line, (size_t)n);
|
||||
(void)w;
|
||||
close(fd);
|
||||
|
||||
return 0;
|
||||
}
|
||||
265
decnet/templates/ssh/capture.sh
Executable file
265
decnet/templates/ssh/capture.sh
Executable file
@@ -0,0 +1,265 @@
|
||||
#!/bin/bash
|
||||
# systemd-journal relay helper: mirrors newly-written files under a
|
||||
# monitored set of paths into the coredump staging directory and emits
|
||||
# a structured journal line per event.
|
||||
#
|
||||
# `lastpipe` runs the tail of `inotify | while` in the current shell so
|
||||
# the process tree stays flat (one bash, not two). Job control must be
|
||||
# off for lastpipe to apply — non-interactive scripts already have it off.
|
||||
shopt -s lastpipe
|
||||
set +m
|
||||
|
||||
set -u
|
||||
|
||||
CAPTURE_DIR="${CAPTURE_DIR:-/var/lib/systemd/coredump}"
|
||||
CAPTURE_MAX_BYTES="${CAPTURE_MAX_BYTES:-52428800}" # 50 MiB
|
||||
CAPTURE_WATCH_PATHS="${CAPTURE_WATCH_PATHS:-/root /tmp /var/tmp /home /var/www /opt /dev/shm}"
|
||||
# Invoke inotifywait through the udev-sided symlink; fall back to the real
|
||||
# binary if the symlink is missing.
|
||||
INOTIFY_BIN="${INOTIFY_BIN:-/usr/libexec/udev/kmsg-watch}"
|
||||
[ -x "$INOTIFY_BIN" ] || INOTIFY_BIN="$(command -v inotifywait)"
|
||||
|
||||
mkdir -p "$CAPTURE_DIR"
|
||||
chmod 700 "$CAPTURE_DIR"
|
||||
|
||||
# Filenames we never capture (boot noise, self-writes).
|
||||
_is_ignored_path() {
|
||||
local p="$1"
|
||||
case "$p" in
|
||||
"$CAPTURE_DIR"/*) return 0 ;;
|
||||
/var/lib/systemd/*) return 0 ;;
|
||||
*/.bash_history) return 0 ;;
|
||||
*/.viminfo) return 0 ;;
|
||||
*/ssh_host_*_key*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve the writer PID best-effort. Prints the PID or nothing.
|
||||
_writer_pid() {
|
||||
local path="$1"
|
||||
local pid
|
||||
pid="$(fuser "$path" 2>/dev/null | tr -d ' \t\n')"
|
||||
if [ -n "$pid" ]; then
|
||||
printf '%s' "${pid%% *}"
|
||||
return
|
||||
fi
|
||||
# Fallback: scan /proc/*/fd for an open handle on the path.
|
||||
for fd_link in /proc/[0-9]*/fd/*; do
|
||||
[ -L "$fd_link" ] || continue
|
||||
if [ "$(readlink -f "$fd_link" 2>/dev/null)" = "$path" ]; then
|
||||
printf '%s' "$(echo "$fd_link" | awk -F/ '{print $3}')"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Walk PPid chain from $1 until we hit an sshd session leader.
|
||||
# Prints: <sshd_pid> <user> (empty on no match).
|
||||
_walk_to_sshd() {
|
||||
local pid="$1"
|
||||
local depth=0
|
||||
while [ -n "$pid" ] && [ "$pid" != "0" ] && [ "$pid" != "1" ] && [ $depth -lt 20 ]; do
|
||||
local cmd
|
||||
cmd="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)"
|
||||
# sshd session leaders look like: "sshd: root@pts/0" or "sshd: root@notty"
|
||||
if echo "$cmd" | grep -qE '^sshd: [^ ]+@'; then
|
||||
local user
|
||||
user="$(echo "$cmd" | sed -E 's/^sshd: ([^@]+)@.*/\1/')"
|
||||
printf '%s %s' "$pid" "$user"
|
||||
return
|
||||
fi
|
||||
pid="$(awk '/^PPid:/ {print $2}' "/proc/$pid/status" 2>/dev/null)"
|
||||
depth=$((depth + 1))
|
||||
done
|
||||
}
|
||||
|
||||
# Emit a JSON array of currently-established SSH peers.
|
||||
# Each item: {pid, src_ip, src_port}.
|
||||
_ss_sessions_json() {
|
||||
ss -Htnp state established sport = :22 2>/dev/null \
|
||||
| awk '
|
||||
{
|
||||
peer=$4; local_=$3;
|
||||
# peer looks like 198.51.100.7:55342 (may be IPv6 [::1]:x)
|
||||
n=split(peer, a, ":");
|
||||
port=a[n];
|
||||
ip=peer; sub(":" port "$", "", ip);
|
||||
gsub(/[\[\]]/, "", ip);
|
||||
# extract pid from users:(("sshd",pid=1234,fd=5))
|
||||
pid="";
|
||||
if (match($0, /pid=[0-9]+/)) {
|
||||
pid=substr($0, RSTART+4, RLENGTH-4);
|
||||
}
|
||||
printf "{\"pid\":%s,\"src_ip\":\"%s\",\"src_port\":%s}\n",
|
||||
(pid==""?"null":pid), ip, (port+0);
|
||||
}' \
|
||||
| jq -s '.'
|
||||
}
|
||||
|
||||
# Emit a JSON array of logged-in users from utmp.
|
||||
# Each item: {user, src_ip, login_at}.
|
||||
_who_sessions_json() {
|
||||
who --ips 2>/dev/null \
|
||||
| awk '{ printf "{\"user\":\"%s\",\"tty\":\"%s\",\"login_at\":\"%s %s\",\"src_ip\":\"%s\"}\n", $1, $2, $3, $4, $NF }' \
|
||||
| jq -s '.'
|
||||
}
|
||||
|
||||
_capture_one() {
|
||||
local src="$1"
|
||||
[ -f "$src" ] || return 0
|
||||
_is_ignored_path "$src" && return 0
|
||||
|
||||
local size
|
||||
size="$(stat -c '%s' "$src" 2>/dev/null)"
|
||||
[ -z "$size" ] && return 0
|
||||
if [ "$size" -gt "$CAPTURE_MAX_BYTES" ]; then
|
||||
logger -p user.info -t systemd-journal "file_skipped size=$size path=$src reason=oversize"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Attribution first — PID may disappear after the copy races.
|
||||
local writer_pid writer_comm writer_cmdline writer_uid writer_loginuid
|
||||
writer_pid="$(_writer_pid "$src")"
|
||||
if [ -n "$writer_pid" ] && [ -d "/proc/$writer_pid" ]; then
|
||||
writer_comm="$(cat "/proc/$writer_pid/comm" 2>/dev/null)"
|
||||
writer_cmdline="$(tr '\0' ' ' < "/proc/$writer_pid/cmdline" 2>/dev/null)"
|
||||
writer_uid="$(awk '/^Uid:/ {print $2}' "/proc/$writer_pid/status" 2>/dev/null)"
|
||||
writer_loginuid="$(cat "/proc/$writer_pid/loginuid" 2>/dev/null)"
|
||||
fi
|
||||
|
||||
local ssh_pid ssh_user
|
||||
if [ -n "$writer_pid" ]; then
|
||||
read -r ssh_pid ssh_user < <(_walk_to_sshd "$writer_pid" || true)
|
||||
fi
|
||||
|
||||
local ss_json who_json
|
||||
ss_json="$(_ss_sessions_json 2>/dev/null || echo '[]')"
|
||||
who_json="$(_who_sessions_json 2>/dev/null || echo '[]')"
|
||||
|
||||
# Resolve src_ip via ss by matching ssh_pid.
|
||||
local src_ip="" src_port="null" attribution="unknown"
|
||||
if [ -n "${ssh_pid:-}" ]; then
|
||||
local matched
|
||||
matched="$(echo "$ss_json" | jq -c --argjson p "$ssh_pid" '.[] | select(.pid==$p)')"
|
||||
if [ -n "$matched" ]; then
|
||||
src_ip="$(echo "$matched" | jq -r '.src_ip')"
|
||||
src_port="$(echo "$matched" | jq -r '.src_port')"
|
||||
attribution="pid-chain"
|
||||
fi
|
||||
fi
|
||||
# Fallback 1: ss-only. scp/wget/sftp close their fd before close_write
|
||||
# fires, so fuser/proc-fd walks miss them. If there's exactly one live
|
||||
# sshd session, attribute to it. With multiple, attribute to the first
|
||||
# but tag ambiguous so analysts know to cross-check concurrent_sessions.
|
||||
if [ "$attribution" = "unknown" ]; then
|
||||
local ss_len
|
||||
ss_len="$(echo "$ss_json" | jq 'length')"
|
||||
if [ "$ss_len" -ge 1 ]; then
|
||||
src_ip="$(echo "$ss_json" | jq -r '.[0].src_ip')"
|
||||
src_port="$(echo "$ss_json" | jq -r '.[0].src_port')"
|
||||
ssh_pid="$(echo "$ss_json" | jq -r '.[0].pid // empty')"
|
||||
if [ -n "${ssh_pid:-}" ] && [ -d "/proc/$ssh_pid" ]; then
|
||||
local ssh_cmd
|
||||
ssh_cmd="$(tr '\0' ' ' < "/proc/$ssh_pid/cmdline" 2>/dev/null)"
|
||||
ssh_user="$(echo "$ssh_cmd" | sed -nE 's/^sshd: ([^@]+)@.*/\1/p')"
|
||||
fi
|
||||
if [ "$ss_len" -eq 1 ]; then
|
||||
attribution="ss-only"
|
||||
else
|
||||
attribution="ss-ambiguous"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback 2: utmp. Weakest signal; often empty in containers.
|
||||
if [ "$attribution" = "unknown" ] && [ "$(echo "$who_json" | jq 'length')" -gt 0 ]; then
|
||||
src_ip="$(echo "$who_json" | jq -r '.[0].src_ip')"
|
||||
attribution="utmp-only"
|
||||
fi
|
||||
|
||||
local sha
|
||||
sha="$(sha256sum "$src" 2>/dev/null | awk '{print $1}')"
|
||||
[ -z "$sha" ] && return 0
|
||||
|
||||
local ts base stored_as
|
||||
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
base="$(basename "$src")"
|
||||
stored_as="${ts}_${sha:0:12}_${base}"
|
||||
|
||||
cp --preserve=timestamps,ownership "$src" "$CAPTURE_DIR/$stored_as" 2>/dev/null || return 0
|
||||
|
||||
local mtime
|
||||
mtime="$(stat -c '%y' "$src" 2>/dev/null)"
|
||||
|
||||
# Prefer NODE_NAME (the deployer-supplied decky identifier) over
|
||||
# $HOSTNAME, which is a cosmetic fake like "SRV-DEV-36" set by
|
||||
# entrypoint.sh. The UI and the artifact bind mount both key on the
|
||||
# decky name, so using $HOSTNAME here makes /artifacts/{decky}/... URLs
|
||||
# unresolvable.
|
||||
local decky="${NODE_NAME:-${HOSTNAME:-unknown}}"
|
||||
|
||||
# One syslog line, no sidecar. Flat summary fields ride as top-level SD
|
||||
# params (searchable pills in the UI); bulky nested structures (writer
|
||||
# cmdline, concurrent_sessions, ss_snapshot) are base64-packed into a
|
||||
# single meta_json_b64 SD param by emit_capture.py.
|
||||
jq -n \
|
||||
--arg _hostname "$decky" \
|
||||
--arg _service "ssh" \
|
||||
--arg _event_type "file_captured" \
|
||||
--arg captured_at "$ts" \
|
||||
--arg orig_path "$src" \
|
||||
--arg stored_as "$stored_as" \
|
||||
--arg sha256 "$sha" \
|
||||
--argjson size "$size" \
|
||||
--arg mtime "$mtime" \
|
||||
--arg attribution "$attribution" \
|
||||
--arg writer_pid "${writer_pid:-}" \
|
||||
--arg writer_comm "${writer_comm:-}" \
|
||||
--arg writer_cmdline "${writer_cmdline:-}" \
|
||||
--arg writer_uid "${writer_uid:-}" \
|
||||
--arg writer_loginuid "${writer_loginuid:-}" \
|
||||
--arg ssh_pid "${ssh_pid:-}" \
|
||||
--arg ssh_user "${ssh_user:-}" \
|
||||
--arg src_ip "$src_ip" \
|
||||
--arg src_port "$src_port" \
|
||||
--argjson concurrent "$who_json" \
|
||||
--argjson ss_snapshot "$ss_json" \
|
||||
'{
|
||||
_hostname: $_hostname,
|
||||
_service: $_service,
|
||||
_event_type: $_event_type,
|
||||
captured_at: $captured_at,
|
||||
orig_path: $orig_path,
|
||||
stored_as: $stored_as,
|
||||
sha256: $sha256,
|
||||
size: $size,
|
||||
mtime: $mtime,
|
||||
attribution: $attribution,
|
||||
writer_pid: $writer_pid,
|
||||
writer_comm: $writer_comm,
|
||||
writer_uid: $writer_uid,
|
||||
ssh_pid: $ssh_pid,
|
||||
ssh_user: $ssh_user,
|
||||
src_ip: $src_ip,
|
||||
src_port: (if $src_port == "null" or $src_port == "" then "" else $src_port end),
|
||||
writer_cmdline: $writer_cmdline,
|
||||
writer_loginuid: $writer_loginuid,
|
||||
concurrent_sessions: $concurrent,
|
||||
ss_snapshot: $ss_snapshot
|
||||
}' \
|
||||
| python3 <(printf '%s' "$EMIT_CAPTURE_PY")
|
||||
}
|
||||
|
||||
# Main loop.
|
||||
# LD_PRELOAD libudev-shared.so.1 blanks argv[1..] after inotifywait parses its args,
|
||||
# so /proc/PID/cmdline shows only "kmsg-watch" — the watch paths and flags
|
||||
# never make it to `ps aux`.
|
||||
# shellcheck disable=SC2086
|
||||
ARGV_ZAP_COMM=kmsg-watch LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libudev-shared.so.1 "$INOTIFY_BIN" -m -r -q \
|
||||
--event close_write --event moved_to \
|
||||
--format '%w%f' \
|
||||
$CAPTURE_WATCH_PATHS 2>/dev/null \
|
||||
| while IFS= read -r path; do
|
||||
_capture_one "$path" &
|
||||
done
|
||||
84
decnet/templates/ssh/emit_capture.py
Normal file
84
decnet/templates/ssh/emit_capture.py
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Emit an RFC 5424 `file_captured` line to stdout.
|
||||
|
||||
Called by capture.sh after a file drop has been mirrored into the quarantine
|
||||
directory. Reads a single JSON object from stdin describing the event; emits
|
||||
one syslog line that the collector parses into `logs.fields`.
|
||||
|
||||
The input JSON may contain arbitrary nested structures (writer cmdline,
|
||||
concurrent_sessions, ss_snapshot). Bulky fields are base64-encoded into a
|
||||
single `meta_json_b64` SD param — this avoids pathological characters
|
||||
(`]`, `"`, `\\`) that the collector's SD-block regex cannot losslessly
|
||||
round-trip when embedded directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from syslog_bridge import syslog_line, write_syslog_file # noqa: E402
|
||||
|
||||
# Flat fields ride as individual SD params (searchable, rendered as pills).
|
||||
# Everything else is rolled into the base64 meta blob.
|
||||
_FLAT_FIELDS: tuple[str, ...] = (
|
||||
"stored_as",
|
||||
"sha256",
|
||||
"size",
|
||||
"orig_path",
|
||||
"src_ip",
|
||||
"src_port",
|
||||
"ssh_user",
|
||||
"ssh_pid",
|
||||
"attribution",
|
||||
"writer_pid",
|
||||
"writer_comm",
|
||||
"writer_uid",
|
||||
"mtime",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = sys.stdin.read()
|
||||
if not raw.strip():
|
||||
print("emit_capture: empty stdin", file=sys.stderr)
|
||||
return 1
|
||||
try:
|
||||
event: dict = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"emit_capture: bad JSON: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
hostname = str(event.pop("_hostname", None) or os.environ.get("HOSTNAME") or "-")
|
||||
service = str(event.pop("_service", "ssh"))
|
||||
event_type = str(event.pop("_event_type", "file_captured"))
|
||||
|
||||
fields: dict[str, str] = {}
|
||||
for key in _FLAT_FIELDS:
|
||||
if key in event:
|
||||
value = event.pop(key)
|
||||
if value is None or value == "":
|
||||
continue
|
||||
fields[key] = str(value)
|
||||
|
||||
if event:
|
||||
payload = json.dumps(event, separators=(",", ":"), ensure_ascii=False, sort_keys=True)
|
||||
fields["meta_json_b64"] = base64.b64encode(payload.encode("utf-8")).decode("ascii")
|
||||
|
||||
line = syslog_line(
|
||||
service=service,
|
||||
hostname=hostname,
|
||||
event_type=event_type,
|
||||
**fields,
|
||||
)
|
||||
write_syslog_file(line)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
90
decnet/templates/ssh/entrypoint.sh
Normal file
90
decnet/templates/ssh/entrypoint.sh
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Configure root password (default: admin)
|
||||
ROOT_PASSWORD="${SSH_ROOT_PASSWORD:-admin}"
|
||||
echo "root:${ROOT_PASSWORD}" | chpasswd
|
||||
|
||||
# Optional: override hostname inside container
|
||||
if [ -n "$SSH_HOSTNAME" ]; then
|
||||
echo "$SSH_HOSTNAME" > /etc/hostname
|
||||
hostname "$SSH_HOSTNAME"
|
||||
fi
|
||||
|
||||
# Generate host keys if missing (first boot)
|
||||
ssh-keygen -A
|
||||
|
||||
# Ensure transcripts dir exists on the quarantine mount. sessrec appends to
|
||||
# one JSONL day-shard per decky; PAM-seeded env vars tell it which service
|
||||
# slot owns this container (used in the session_recorded syslog event).
|
||||
mkdir -p /var/lib/systemd/coredump/transcripts
|
||||
chmod 750 /var/lib/systemd/coredump/transcripts
|
||||
echo "ssh" > /etc/sessrec.service
|
||||
{
|
||||
echo "SESSREC_SERVICE=ssh"
|
||||
[ -n "${NODE_NAME:-}" ] && echo "NODE_NAME=${NODE_NAME}"
|
||||
} >> /etc/environment
|
||||
|
||||
# Fake bash history so the box looks used
|
||||
if [ ! -f /root/.bash_history ]; then
|
||||
cat > /root/.bash_history <<'HIST'
|
||||
apt update && apt upgrade -y
|
||||
systemctl status nginx
|
||||
tail -f /var/log/syslog
|
||||
df -h
|
||||
htop
|
||||
ps aux | grep python
|
||||
git pull origin main
|
||||
cd /root/projects
|
||||
vim notes.txt
|
||||
crontab -e
|
||||
ls /var/www/html
|
||||
HIST
|
||||
fi
|
||||
|
||||
# Logging pipeline: rsyslogd (RFC 5424) → /proc/1/fd/1 → Docker log capture.
|
||||
# No intermediate pipe/relay — a named FIFO would be readable AND writable
|
||||
# by any root-in-container process, letting an attacker either eavesdrop on
|
||||
# the SIEM feed or inject forged log lines.
|
||||
if [ -n "${NODE_NAME:-}" ]; then
|
||||
printf '$LocalHostName %s\n' "$NODE_NAME" > /etc/rsyslog.d/00-localhostname.conf
|
||||
fi
|
||||
rsyslogd
|
||||
|
||||
# ─── systemd-journal companion bootstrap ─────────────────────────────────────
|
||||
# The next block primes cached udev/journal helpers from in-memory tables.
|
||||
# Tables are XOR+gzip+base64 packed at image build. Keeping the data inline
|
||||
# avoids a separate /usr/libexec script (which would be visible and readable)
|
||||
# and means the helpers only ever live in this process's memory + an
|
||||
# anonymous pipe the kernel hands to python via /dev/fd.
|
||||
_STEALTH_KEY=__STEALTH_KEY__
|
||||
_EMIT_CAPTURE_B64='__EMIT_CAPTURE_B64__'
|
||||
_JOURNAL_RELAY_B64='__JOURNAL_RELAY_B64__'
|
||||
|
||||
_decode() {
|
||||
printf '%s' "$1" | base64 -d | python3 -c '
|
||||
import sys
|
||||
k = '"$_STEALTH_KEY"'
|
||||
d = sys.stdin.buffer.read()
|
||||
sys.stdout.buffer.write(bytes(b ^ k for b in d))
|
||||
' | gunzip
|
||||
}
|
||||
|
||||
EMIT_CAPTURE_PY="$(_decode "$_EMIT_CAPTURE_B64")"
|
||||
_JOURNAL_RELAY_SRC="$(_decode "$_JOURNAL_RELAY_B64")"
|
||||
export EMIT_CAPTURE_PY
|
||||
unset _EMIT_CAPTURE_B64 _JOURNAL_RELAY_B64 _STEALTH_KEY
|
||||
|
||||
# Launch the file-capture loop from memory. LD_PRELOAD + ARGV_ZAP_COMM blank
|
||||
# argv[1..] so /proc/PID/cmdline shows only "journal-relay".
|
||||
(
|
||||
export CAPTURE_DIR=/var/lib/systemd/coredump
|
||||
export LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libudev-shared.so.1
|
||||
export ARGV_ZAP_COMM=journal-relay
|
||||
exec -a journal-relay bash -c "$_JOURNAL_RELAY_SRC"
|
||||
) &
|
||||
|
||||
unset _JOURNAL_RELAY_SRC
|
||||
|
||||
# sshd logs via syslog — no -e flag, so auth events flow through rsyslog → /proc/1/fd/1 → stdout
|
||||
exec /usr/sbin/sshd -D
|
||||
120
decnet/templates/ssh/instance_seed.py
Normal file
120
decnet/templates/ssh/instance_seed.py
Normal 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)
|
||||
28
decnet/templates/ssh/sessrec/Makefile
Normal file
28
decnet/templates/ssh/sessrec/Makefile
Normal file
@@ -0,0 +1,28 @@
|
||||
# Build sessrec, a tiny pty relay + transcript recorder installed as the
|
||||
# login shell inside SSH / Telnet decky containers. Built per-image during
|
||||
# the template Dockerfile's build stage; gcc + libc6-dev are installed only
|
||||
# for this step and purged in the same layer.
|
||||
#
|
||||
# Output: /usr/libexec/login-session (plausible login-machinery name)
|
||||
|
||||
CC ?= gcc
|
||||
CFLAGS ?= -O2 -Wall -Wextra -D_FORTIFY_SOURCE=2 -fstack-protector-strong -fPIE
|
||||
LDFLAGS ?= -pie -Wl,-z,relro,-z,now
|
||||
LIBS := -lutil
|
||||
|
||||
PREFIX ?= /usr/libexec
|
||||
TARGET := login-session
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(TARGET): sessrec.c
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $< $(LIBS)
|
||||
strip --strip-unneeded $@
|
||||
|
||||
install: $(TARGET)
|
||||
install -D -m 0755 $(TARGET) $(DESTDIR)$(PREFIX)/$(TARGET)
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET)
|
||||
|
||||
.PHONY: all install clean
|
||||
564
decnet/templates/ssh/sessrec/sessrec.c
Normal file
564
decnet/templates/ssh/sessrec/sessrec.c
Normal file
@@ -0,0 +1,564 @@
|
||||
/*
|
||||
* sessrec — interactive session recorder for SSH / Telnet deckies.
|
||||
*
|
||||
* Invoked as the login shell (via /etc/passwd shell swap). On interactive tty
|
||||
* sessions it:
|
||||
* 1. forkpty()'s /bin/bash -l and relays stdin/stdout/SIGWINCH bidirectionally;
|
||||
* 2. records each chunk as an asciinema v2 event in a *shared* JSONL day-shard
|
||||
* (/var/lib/systemd/coredump/transcripts/sessions-YYYY-MM-DD.jsonl) with
|
||||
* the session's UUID as a sid tag on every line;
|
||||
* 3. on exit emits one RFC 5424 syslog line (event_type=session_recorded)
|
||||
* direct to PID 1's stdout — bypasses rsyslog the same way syslog_bridge.py
|
||||
* does in the Python service templates.
|
||||
*
|
||||
* Storage shape is one JSONL shard per (decky, UTC day). Concurrent sessions
|
||||
* append the shard lock-free: each write() is < PIPE_BUF (4096) and O_APPEND
|
||||
* guarantees atomic interleave on Linux regular files. Events larger than one
|
||||
* atomic write are chunked. Per-session cap: 10 MB; overflow writes one sentinel
|
||||
* line and stops emitting (session itself continues). Disk-free precheck on the
|
||||
* shard mount; below 200 MB free we emit session_skipped and exec bash directly.
|
||||
*
|
||||
* Non-tty invocation (e.g. `ssh host cmd`) short-circuits to execvp(bash) so
|
||||
* non-interactive command execution still surfaces via the existing
|
||||
* PROMPT_COMMAND logger hook rather than this path.
|
||||
*/
|
||||
|
||||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netdb.h>
|
||||
#include <pty.h>
|
||||
#include <signal.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/statvfs.h>
|
||||
#include <sys/time.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <termios.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include <utmp.h>
|
||||
#include <poll.h>
|
||||
|
||||
#define TRANSCRIPTS_DIR "/var/lib/systemd/coredump/transcripts"
|
||||
#define PID1_STDOUT "/proc/1/fd/1"
|
||||
#define MIN_FREE_BYTES ((uint64_t)200 * 1024 * 1024) /* 200 MB disk precheck */
|
||||
#define SESSION_CAP_BYTES ((uint64_t) 10 * 1024 * 1024) /* 10 MB per-session cap */
|
||||
#define ATOMIC_CHUNK 3900 /* < PIPE_BUF (4096) */
|
||||
#define BUF_SIZE 4096
|
||||
#define LINE_SCRATCH (ATOMIC_CHUNK * 2 + 512)
|
||||
#define DEFAULT_SHELL "/bin/bash"
|
||||
#define COMM_DISGUISE "kworker/u8:2-ev" /* fits 15-char comm cap */
|
||||
|
||||
/* ─── tiny utilities ──────────────────────────────────────────────────────── */
|
||||
|
||||
static volatile sig_atomic_t sigwinch_pending = 0;
|
||||
|
||||
static void sigwinch_handler(int sig) { (void)sig; sigwinch_pending = 1; }
|
||||
|
||||
static double monotonic_since(const struct timespec *t0) {
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
double dt = (double)(now.tv_sec - t0->tv_sec)
|
||||
+ (double)(now.tv_nsec - t0->tv_nsec) / 1e9;
|
||||
return dt < 0.0 ? 0.0 : dt;
|
||||
}
|
||||
|
||||
/* Write all bytes, retrying on EINTR. Returns 0 on success. */
|
||||
static int write_all(int fd, const void *buf, size_t n) {
|
||||
const uint8_t *p = buf;
|
||||
while (n > 0) {
|
||||
ssize_t w = write(fd, p, n);
|
||||
if (w < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
return -1;
|
||||
}
|
||||
p += w; n -= (size_t)w;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Pick 16 bytes of entropy, format as UUIDv4 (8-4-4-4-12 hex, 36 chars + NUL). */
|
||||
static int mint_uuid(char out[37]) {
|
||||
int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0) return -1;
|
||||
uint8_t b[16];
|
||||
ssize_t n = read(fd, b, sizeof b);
|
||||
close(fd);
|
||||
if (n != (ssize_t)sizeof b) return -1;
|
||||
b[6] = (b[6] & 0x0f) | 0x40; /* v4 */
|
||||
b[8] = (b[8] & 0x3f) | 0x80; /* variant */
|
||||
snprintf(out, 37,
|
||||
"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
|
||||
b[0],b[1],b[2],b[3], b[4],b[5], b[6],b[7], b[8],b[9],
|
||||
b[10],b[11],b[12],b[13],b[14],b[15]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* JSON-escape raw bytes into dst. Returns written length (excluding NUL),
|
||||
* or -1 on overflow. Handles control chars, quote, backslash, and non-UTF8
|
||||
* bytes (emitted as \u00XX so the output stays valid JSON regardless of
|
||||
* terminal payload encoding). */
|
||||
static ssize_t json_escape(char *dst, size_t cap, const uint8_t *src, size_t n) {
|
||||
size_t o = 0;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
uint8_t c = src[i];
|
||||
const char *esc = NULL;
|
||||
char buf[8];
|
||||
size_t add;
|
||||
switch (c) {
|
||||
case '"': esc = "\\\""; add = 2; break;
|
||||
case '\\': esc = "\\\\"; add = 2; break;
|
||||
case '\b': esc = "\\b"; add = 2; break;
|
||||
case '\f': esc = "\\f"; add = 2; break;
|
||||
case '\n': esc = "\\n"; add = 2; break;
|
||||
case '\r': esc = "\\r"; add = 2; break;
|
||||
case '\t': esc = "\\t"; add = 2; break;
|
||||
default:
|
||||
if (c < 0x20 || c == 0x7f) {
|
||||
snprintf(buf, sizeof buf, "\\u%04x", c);
|
||||
esc = buf; add = 6;
|
||||
} else {
|
||||
esc = NULL; add = 1;
|
||||
}
|
||||
}
|
||||
if (o + add + 1 >= cap) return -1;
|
||||
if (esc) { memcpy(dst + o, esc, add); o += add; }
|
||||
else { dst[o++] = (char)c; }
|
||||
}
|
||||
dst[o] = '\0';
|
||||
return (ssize_t)o;
|
||||
}
|
||||
|
||||
/* ─── disk precheck + shard resolution ────────────────────────────────────── */
|
||||
|
||||
static uint64_t free_bytes(const char *path) {
|
||||
struct statvfs s;
|
||||
if (statvfs(path, &s) != 0) return 0;
|
||||
return (uint64_t)s.f_bavail * (uint64_t)s.f_frsize;
|
||||
}
|
||||
|
||||
static void today_utc(char out[11]) {
|
||||
time_t t = time(NULL);
|
||||
struct tm tm;
|
||||
gmtime_r(&t, &tm);
|
||||
strftime(out, 11, "%Y-%m-%d", &tm);
|
||||
}
|
||||
|
||||
/* Build /var/lib/systemd/coredump/transcripts/sessions-YYYY-MM-DD.jsonl */
|
||||
static void shard_path(char out[512]) {
|
||||
char day[11];
|
||||
today_utc(day);
|
||||
snprintf(out, 512, "%s/sessions-%s.jsonl", TRANSCRIPTS_DIR, day);
|
||||
}
|
||||
|
||||
/* ─── src_ip resolution ───────────────────────────────────────────────────── */
|
||||
|
||||
static void resolve_src_ip(char out[NI_MAXHOST]) {
|
||||
out[0] = '\0';
|
||||
|
||||
/* SSH: $SSH_CONNECTION = "<client_ip> <client_port> <server_ip> <server_port>" */
|
||||
const char *sc = getenv("SSH_CONNECTION");
|
||||
if (sc && *sc) {
|
||||
size_t i = 0;
|
||||
while (sc[i] && sc[i] != ' ' && i < NI_MAXHOST - 1) {
|
||||
out[i] = sc[i]; i++;
|
||||
}
|
||||
out[i] = '\0';
|
||||
if (out[0]) return;
|
||||
}
|
||||
|
||||
/* Telnet: busybox telnetd -l /bin/login leaves the client socket as fd 0. */
|
||||
struct sockaddr_storage ss;
|
||||
socklen_t sl = sizeof ss;
|
||||
if (getpeername(STDIN_FILENO, (struct sockaddr *)&ss, &sl) == 0) {
|
||||
if (getnameinfo((struct sockaddr *)&ss, sl, out, NI_MAXHOST,
|
||||
NULL, 0, NI_NUMERICHOST) == 0 && out[0]) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* Last-resort: utmp host field for the current tty. */
|
||||
char ttybuf[64];
|
||||
if (ttyname_r(STDIN_FILENO, ttybuf, sizeof ttybuf) == 0) {
|
||||
const char *short_tty = ttybuf;
|
||||
if (strncmp(short_tty, "/dev/", 5) == 0) short_tty += 5;
|
||||
setutent();
|
||||
struct utmp *u;
|
||||
while ((u = getutent()) != NULL) {
|
||||
if (u->ut_type == USER_PROCESS &&
|
||||
strncmp(u->ut_line, short_tty, sizeof u->ut_line) == 0) {
|
||||
size_t cap = sizeof u->ut_host;
|
||||
if (cap > NI_MAXHOST - 1) cap = NI_MAXHOST - 1;
|
||||
memcpy(out, u->ut_host, cap);
|
||||
out[cap] = '\0';
|
||||
break;
|
||||
}
|
||||
}
|
||||
endutent();
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── shard emitters ──────────────────────────────────────────────────────── */
|
||||
|
||||
/* Emit a single line via O_APPEND on the shard. Line must include trailing \n
|
||||
* and be < ATOMIC_CHUNK for atomic-append guarantees. */
|
||||
static int shard_emit(int fd, const char *line, size_t n) {
|
||||
if (n == 0) return 0;
|
||||
/* Single write() < PIPE_BUF is atomic under O_APPEND (POSIX.1-2017 §7.1.1,
|
||||
* Linux write(2) NOTES). Don't loop — partial writes don't happen for
|
||||
* regular files under this size and a retry would break atomicity. */
|
||||
ssize_t w = write(fd, line, n);
|
||||
return (w == (ssize_t)n) ? 0 : -1;
|
||||
}
|
||||
|
||||
static void emit_header(int fd, const char *sid, unsigned short cols,
|
||||
unsigned short rows, time_t unix_ts) {
|
||||
/* Sanitize $TERM — attacker-controlled via the ssh client. */
|
||||
const char *raw_term = getenv("TERM");
|
||||
if (!raw_term || !*raw_term) raw_term = "xterm-256color";
|
||||
char term[64];
|
||||
if (json_escape(term, sizeof term,
|
||||
(const uint8_t *)raw_term, strnlen(raw_term, 63)) < 0) {
|
||||
term[0] = '-'; term[1] = '\0';
|
||||
}
|
||||
|
||||
char line[LINE_SCRATCH];
|
||||
int n = snprintf(line, sizeof line,
|
||||
"{\"sid\":\"%s\",\"hdr\":{\"version\":2,\"width\":%u,\"height\":%u,"
|
||||
"\"timestamp\":%lld,\"env\":{\"SHELL\":\"/bin/bash\",\"TERM\":\"%s\"}}}\n",
|
||||
sid, (unsigned)cols, (unsigned)rows, (long long)unix_ts, term);
|
||||
if (n > 0 && n < (int)sizeof line) shard_emit(fd, line, (size_t)n);
|
||||
}
|
||||
|
||||
/* Emit a single ≤ATOMIC_CHUNK event line. Caller is responsible for chunking. */
|
||||
static int emit_event_chunk(int fd, const char *sid, double t,
|
||||
char ch, const uint8_t *data, size_t n) {
|
||||
static char scratch[LINE_SCRATCH];
|
||||
char escaped[LINE_SCRATCH];
|
||||
if (json_escape(escaped, sizeof escaped, data, n) < 0) return -1;
|
||||
int w = snprintf(scratch, sizeof scratch,
|
||||
"{\"sid\":\"%s\",\"t\":%.6f,\"ch\":\"%c\",\"d\":\"%s\"}\n",
|
||||
sid, t, ch, escaped);
|
||||
if (w <= 0 || w >= (int)sizeof scratch) return -1;
|
||||
return shard_emit(fd, scratch, (size_t)w);
|
||||
}
|
||||
|
||||
static void emit_resize(int fd, const char *sid, double t,
|
||||
unsigned short cols, unsigned short rows) {
|
||||
char line[256];
|
||||
int n = snprintf(line, sizeof line,
|
||||
"{\"sid\":\"%s\",\"t\":%.6f,\"ch\":\"r\",\"d\":\"%ux%u\"}\n",
|
||||
sid, t, (unsigned)cols, (unsigned)rows);
|
||||
if (n > 0 && n < (int)sizeof line) shard_emit(fd, line, (size_t)n);
|
||||
}
|
||||
|
||||
static void emit_trunc_sentinel(int fd, const char *sid) {
|
||||
char line[128];
|
||||
int n = snprintf(line, sizeof line, "{\"sid\":\"%s\",\"trunc\":true}\n", sid);
|
||||
if (n > 0) shard_emit(fd, line, (size_t)n);
|
||||
}
|
||||
|
||||
/* Escape an SD-PARAM-VALUE per RFC 5424 §6.3.3 — backslash, double-quote, and
|
||||
* right bracket must be backslash-escaped; everything else is passed through.
|
||||
* Also drops control chars (< 0x20) and 0x7F since they wreck the collector's
|
||||
* line-oriented parser. */
|
||||
static void sd_escape(char *dst, size_t cap, const char *src) {
|
||||
size_t o = 0;
|
||||
if (cap == 0) return;
|
||||
for (size_t i = 0; src[i] && o + 2 < cap; i++) {
|
||||
unsigned char c = (unsigned char)src[i];
|
||||
if (c < 0x20 || c == 0x7f) continue;
|
||||
if (c == '\\' || c == '"' || c == ']') {
|
||||
if (o + 3 >= cap) break;
|
||||
dst[o++] = '\\';
|
||||
}
|
||||
dst[o++] = (char)c;
|
||||
}
|
||||
dst[o] = '\0';
|
||||
}
|
||||
|
||||
/* ─── syslog emitters (direct to PID 1 stdout) ────────────────────────────── */
|
||||
|
||||
/* Format & write an RFC 5424 line with a [relay@55555 ...] SD block matching
|
||||
* what decnet/templates/syslog_bridge.py emits. Routes the line to PID 1's
|
||||
* stdout fd so the container's Docker log stream picks it up — same channel
|
||||
* the other service templates use. */
|
||||
static void syslog_emit(const char *event_type, const char *sd_params,
|
||||
const char *msg) {
|
||||
int fd = open(PID1_STDOUT, O_WRONLY | O_APPEND | O_CLOEXEC);
|
||||
if (fd < 0) return;
|
||||
|
||||
const char *node = getenv("NODE_NAME");
|
||||
if (!node || !*node) node = "-";
|
||||
|
||||
char ts[64];
|
||||
struct timespec tsp;
|
||||
clock_gettime(CLOCK_REALTIME, &tsp);
|
||||
struct tm tm;
|
||||
gmtime_r(&tsp.tv_sec, &tm);
|
||||
int n = (int)strftime(ts, sizeof ts, "%Y-%m-%dT%H:%M:%S", &tm);
|
||||
snprintf(ts + n, sizeof ts - n, ".%06ld+00:00", tsp.tv_nsec / 1000);
|
||||
|
||||
char line[LINE_SCRATCH];
|
||||
int w = snprintf(line, sizeof line,
|
||||
"<134>1 %s %s sessrec - %s [relay@55555 %s]%s%s\n",
|
||||
ts, node, event_type, sd_params ? sd_params : "",
|
||||
msg && *msg ? " " : "", msg ? msg : "");
|
||||
if (w > 0 && w < (int)sizeof line) write_all(fd, line, (size_t)w);
|
||||
close(fd);
|
||||
}
|
||||
|
||||
/* ─── main relay ─────────────────────────────────────────────────────────── */
|
||||
|
||||
static int open_shard(void) {
|
||||
if (mkdir(TRANSCRIPTS_DIR, 0700) != 0 && errno != EEXIST) return -1;
|
||||
char path[512];
|
||||
shard_path(path);
|
||||
return open(path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0640);
|
||||
}
|
||||
|
||||
/* Emit an "o" or "i" event, chunking to ATOMIC_CHUNK and tracking bytes_used
|
||||
* against SESSION_CAP_BYTES. On cap crossing, emits the sentinel once and
|
||||
* returns non-zero so the caller stops emitting for this sid. */
|
||||
static int emit_chunked(int fd, const char *sid, double t, char ch,
|
||||
const uint8_t *data, size_t n,
|
||||
uint64_t *bytes_used, int *truncated) {
|
||||
if (*truncated) return 0;
|
||||
size_t off = 0;
|
||||
while (off < n) {
|
||||
size_t take = n - off;
|
||||
if (take > ATOMIC_CHUNK / 4) take = ATOMIC_CHUNK / 4;
|
||||
/* /4 because each raw byte can expand up to 6x under JSON \u00XX
|
||||
* escaping. Keeps the final line < ATOMIC_CHUNK. */
|
||||
if (emit_event_chunk(fd, sid, t, ch, data + off, take) != 0) {
|
||||
/* Shard write failed — treat as truncation to avoid infinite retry
|
||||
* loop and to keep the pty relay going. */
|
||||
*truncated = 1;
|
||||
emit_trunc_sentinel(fd, sid);
|
||||
return 1;
|
||||
}
|
||||
*bytes_used += take;
|
||||
off += take;
|
||||
if (*bytes_used >= SESSION_CAP_BYTES) {
|
||||
*truncated = 1;
|
||||
emit_trunc_sentinel(fd, sid);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void run_relay(int shard_fd, const char *sid, const char *src_ip,
|
||||
const char *service) {
|
||||
/* Capture parent tty state so we can restore + copy winsize to the pty. */
|
||||
struct termios orig_t, raw_t;
|
||||
int have_orig = (tcgetattr(STDIN_FILENO, &orig_t) == 0);
|
||||
struct winsize ws = {24, 80, 0, 0};
|
||||
ioctl(STDIN_FILENO, TIOCGWINSZ, &ws);
|
||||
|
||||
emit_header(shard_fd, sid, ws.ws_col, ws.ws_row, time(NULL));
|
||||
|
||||
int master_fd = -1;
|
||||
pid_t child = forkpty(&master_fd, NULL, have_orig ? &orig_t : NULL, &ws);
|
||||
if (child < 0) {
|
||||
/* Give up recording; fall through to plain shell. */
|
||||
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
if (child == 0) {
|
||||
/* Child: the login shell. exec into bash, leaving the pty as its ctty. */
|
||||
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
/* Parent: raw mode on the local tty so keystrokes pass through unmolested. */
|
||||
if (have_orig) {
|
||||
raw_t = orig_t;
|
||||
cfmakeraw(&raw_t);
|
||||
tcsetattr(STDIN_FILENO, TCSANOW, &raw_t);
|
||||
}
|
||||
|
||||
struct sigaction sa = {0};
|
||||
sa.sa_handler = sigwinch_handler;
|
||||
sigemptyset(&sa.sa_mask);
|
||||
sa.sa_flags = SA_RESTART;
|
||||
sigaction(SIGWINCH, &sa, NULL);
|
||||
|
||||
struct timespec t0;
|
||||
clock_gettime(CLOCK_MONOTONIC, &t0);
|
||||
|
||||
uint64_t bytes_used = 0;
|
||||
int truncated = 0;
|
||||
|
||||
uint8_t buf[BUF_SIZE];
|
||||
struct pollfd pfds[2] = {
|
||||
{ .fd = STDIN_FILENO, .events = POLLIN },
|
||||
{ .fd = master_fd, .events = POLLIN },
|
||||
};
|
||||
|
||||
int child_alive = 1;
|
||||
while (child_alive) {
|
||||
if (sigwinch_pending) {
|
||||
sigwinch_pending = 0;
|
||||
struct winsize nw;
|
||||
if (ioctl(STDIN_FILENO, TIOCGWINSZ, &nw) == 0) {
|
||||
ioctl(master_fd, TIOCSWINSZ, &nw);
|
||||
if (!truncated) emit_resize(shard_fd, sid,
|
||||
monotonic_since(&t0),
|
||||
nw.ws_col, nw.ws_row);
|
||||
}
|
||||
}
|
||||
|
||||
int r = poll(pfds, 2, 1000);
|
||||
if (r < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
break;
|
||||
}
|
||||
|
||||
if (pfds[0].revents & POLLIN) {
|
||||
ssize_t n = read(STDIN_FILENO, buf, sizeof buf);
|
||||
if (n > 0) {
|
||||
write_all(master_fd, buf, (size_t)n);
|
||||
emit_chunked(shard_fd, sid, monotonic_since(&t0), 'i',
|
||||
buf, (size_t)n, &bytes_used, &truncated);
|
||||
} else if (n == 0) {
|
||||
/* stdin EOF — close master so the shell sees EOF too. */
|
||||
close(master_fd);
|
||||
master_fd = -1;
|
||||
pfds[1].fd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (master_fd >= 0 && (pfds[1].revents & POLLIN)) {
|
||||
ssize_t n = read(master_fd, buf, sizeof buf);
|
||||
if (n > 0) {
|
||||
write_all(STDOUT_FILENO, buf, (size_t)n);
|
||||
emit_chunked(shard_fd, sid, monotonic_since(&t0), 'o',
|
||||
buf, (size_t)n, &bytes_used, &truncated);
|
||||
} else {
|
||||
/* pty master EOF = shell exited. */
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ((pfds[0].revents | pfds[1].revents) & (POLLHUP | POLLERR | POLLNVAL)) {
|
||||
if (pfds[1].revents & (POLLHUP | POLLERR | POLLNVAL)) break;
|
||||
}
|
||||
|
||||
/* Reap without blocking; tolerate children that exit slightly before
|
||||
* we see the master EOF. */
|
||||
int status;
|
||||
pid_t r2 = waitpid(child, &status, WNOHANG);
|
||||
if (r2 == child) {
|
||||
child_alive = 0;
|
||||
/* Let pty flush remaining output on the next poll cycle. */
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* Final reap. */
|
||||
int status = 0;
|
||||
if (child_alive) waitpid(child, &status, 0);
|
||||
if (master_fd >= 0) close(master_fd);
|
||||
|
||||
if (have_orig) tcsetattr(STDIN_FILENO, TCSANOW, &orig_t);
|
||||
|
||||
double duration = monotonic_since(&t0);
|
||||
|
||||
/* src_ip is always an IP literal (getnameinfo NI_NUMERICHOST or an IPv4/6
|
||||
* token from $SSH_CONNECTION / utmp). 128 B is enough for IPv6 + zone id
|
||||
* + escaping headroom, and keeps the syslog line bounded. */
|
||||
char ip_esc[128];
|
||||
sd_escape(ip_esc, sizeof ip_esc, src_ip[0] ? src_ip : "-");
|
||||
|
||||
char sd[1024];
|
||||
snprintf(sd, sizeof sd,
|
||||
"sid=\"%s\" service=\"%s\" src_ip=\"%s\" duration_s=\"%.3f\" "
|
||||
"bytes=\"%llu\" truncated=\"%s\"",
|
||||
sid, service, ip_esc, duration,
|
||||
(unsigned long long)bytes_used, truncated ? "true" : "false");
|
||||
syslog_emit("session_recorded", sd, NULL);
|
||||
}
|
||||
|
||||
/* ─── main ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
(void)argc; (void)argv;
|
||||
prctl(PR_SET_NAME, (unsigned long)COMM_DISGUISE, 0, 0, 0);
|
||||
|
||||
/* Non-interactive (`ssh host cmd`) — bypass recording entirely. The
|
||||
* existing PROMPT_COMMAND syslog hook still logs the single command. */
|
||||
if (!isatty(STDIN_FILENO)) {
|
||||
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
/* Disk pressure: skip recording, fall through to plain shell. */
|
||||
if (free_bytes(TRANSCRIPTS_DIR) < MIN_FREE_BYTES &&
|
||||
free_bytes("/var/lib/systemd/coredump") < MIN_FREE_BYTES) {
|
||||
/* statvfs on the transcripts dir may fail if not yet created; check
|
||||
* the parent mount as a fallback before deciding. */
|
||||
syslog_emit("session_skipped", "reason=\"disk_pressure\"", NULL);
|
||||
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
int shard_fd = open_shard();
|
||||
if (shard_fd < 0) {
|
||||
syslog_emit("session_skipped", "reason=\"shard_open_failed\"", NULL);
|
||||
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
char sid[37];
|
||||
if (mint_uuid(sid) != 0) {
|
||||
close(shard_fd);
|
||||
execlp(DEFAULT_SHELL, DEFAULT_SHELL, "-l", (char *)NULL);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
/* Service discriminant: env var SESSREC_SERVICE set by the template
|
||||
* entrypoint (ssh vs telnet). SSH forwards env via PAM; busybox /bin/login
|
||||
* strips env, so as a fallback we read /etc/sessrec.service, a one-line
|
||||
* file the template entrypoint writes at boot. */
|
||||
const char *service = getenv("SESSREC_SERVICE");
|
||||
static char svc_buf[16];
|
||||
if (!service || !*service) {
|
||||
FILE *sf = fopen("/etc/sessrec.service", "r");
|
||||
if (sf) {
|
||||
if (fgets(svc_buf, sizeof svc_buf, sf)) {
|
||||
size_t n = strlen(svc_buf);
|
||||
while (n > 0 && (svc_buf[n - 1] == '\n' || svc_buf[n - 1] == ' ')) {
|
||||
svc_buf[--n] = '\0';
|
||||
}
|
||||
if (svc_buf[0]) service = svc_buf;
|
||||
}
|
||||
fclose(sf);
|
||||
}
|
||||
}
|
||||
if (!service || !*service) service = "ssh";
|
||||
|
||||
char src_ip[NI_MAXHOST];
|
||||
resolve_src_ip(src_ip);
|
||||
|
||||
/* Hostname banner — /bin/login emits "Last login: …" before exec'ing the
|
||||
* shell; we want our header anchored before the shell starts writing, so
|
||||
* emit_header() has already run inside run_relay(). */
|
||||
|
||||
run_relay(shard_fd, sid, src_ip, service);
|
||||
close(shard_fd);
|
||||
|
||||
/* Exit code mirrors the shell's — a bash logout shouldn't surface here
|
||||
* as an error to the parent (sshd / login). */
|
||||
return 0;
|
||||
}
|
||||
261
decnet/templates/ssh/syslog_bridge.py
Normal file
261
decnet/templates/ssh/syslog_bridge.py
Normal 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
|
||||
Reference in New Issue
Block a user