feat(telnet): same PAM cred-capture, /etc/pam.d/login
Promotes auth-helper.c to decnet/templates/_shared/auth-helper/ and adds _sync_auth_helper_sources() — mirrors the existing sessrec sync pattern that keeps shared sources in step with per-template build contexts. Telnet's image grows the same multi-stage musl build, COPY of the static helper into /usr/sbin/auth-helper, and prepended pam_exec line in /etc/pam.d/login. Pulls in the `login` package (real Debian PAM-aware /bin/login, replacing busybox's PAM-less applet) and libpam-modules transitively for pam_exec.so. Verified inside the rebuilt telnet image: - /bin/login is the real 53KB Debian binary (PAM-aware) - /etc/pam.d/login top line is the auth-helper hook - pam_exec.so present at /usr/lib/x86_64-linux-gnu/security/pam_exec.so - helper smoke-run emits correct RFC 5424 line for `telnetpw` → password_b64="dGVsbmV0cHc=" SSH Dockerfile updated to read auth-helper.c from auth-helper/ subdirectory so both templates use the synced layout. The canonical source lives in _shared/; per-template copies are tracked in git AND synced at deploy time so a drift on either side rebases on the next deploy. Closes the telnet half of DEBT-038's #5 follow-up.
This commit is contained in:
@@ -53,6 +53,8 @@ _CANONICAL_LOGGING = Path(__file__).parent.parent / "templates" / "syslog_bridge
|
|||||||
_CANONICAL_INSTANCE_SEED = Path(__file__).parent.parent / "templates" / "instance_seed.py"
|
_CANONICAL_INSTANCE_SEED = Path(__file__).parent.parent / "templates" / "instance_seed.py"
|
||||||
_CANONICAL_SESSREC_DIR = Path(__file__).parent.parent / "templates" / "_shared" / "sessrec"
|
_CANONICAL_SESSREC_DIR = Path(__file__).parent.parent / "templates" / "_shared" / "sessrec"
|
||||||
_SESSREC_SERVICES = {"ssh", "telnet"}
|
_SESSREC_SERVICES = {"ssh", "telnet"}
|
||||||
|
_CANONICAL_AUTH_HELPER_DIR = Path(__file__).parent.parent / "templates" / "_shared" / "auth-helper"
|
||||||
|
_AUTH_HELPER_SERVICES = {"ssh", "telnet"}
|
||||||
|
|
||||||
|
|
||||||
def _sync_logging_helper(config: DecnetConfig) -> None:
|
def _sync_logging_helper(config: DecnetConfig) -> None:
|
||||||
@@ -75,6 +77,37 @@ def _sync_logging_helper(config: DecnetConfig) -> None:
|
|||||||
shutil.copy2(src, dest)
|
shutil.copy2(src, dest)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_auth_helper_sources(config: DecnetConfig) -> None:
|
||||||
|
"""Copy auth-helper.c into SSH/Telnet build contexts as auth-helper/.
|
||||||
|
|
||||||
|
The static cred-capture binary (compiled in a multi-stage Dockerfile
|
||||||
|
layer via musl-gcc) is service-agnostic — same source compiles for
|
||||||
|
both sshd's PAM stack (/etc/pam.d/sshd) and busybox-telnetd's
|
||||||
|
/bin/login PAM stack (/etc/pam.d/login). Mirrors the sessrec sync
|
||||||
|
pattern below.
|
||||||
|
"""
|
||||||
|
from decnet.services.registry import get_service
|
||||||
|
sources = [_CANONICAL_AUTH_HELPER_DIR / "auth-helper.c"]
|
||||||
|
seen: set[Path] = set()
|
||||||
|
for decky in config.deckies:
|
||||||
|
for svc_name in decky.services:
|
||||||
|
if svc_name not in _AUTH_HELPER_SERVICES:
|
||||||
|
continue
|
||||||
|
svc = get_service(svc_name)
|
||||||
|
if svc is None:
|
||||||
|
continue
|
||||||
|
ctx = svc.dockerfile_context()
|
||||||
|
if ctx is None or ctx in seen:
|
||||||
|
continue
|
||||||
|
seen.add(ctx)
|
||||||
|
dest_dir = ctx / "auth-helper"
|
||||||
|
dest_dir.mkdir(exist_ok=True)
|
||||||
|
for src in sources:
|
||||||
|
dest = dest_dir / src.name
|
||||||
|
if not dest.exists() or dest.read_bytes() != src.read_bytes():
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
|
||||||
|
|
||||||
def _sync_sessrec_sources(config: DecnetConfig) -> None:
|
def _sync_sessrec_sources(config: DecnetConfig) -> None:
|
||||||
"""Copy sessrec.c + Makefile into SSH/Telnet build contexts as sessrec/."""
|
"""Copy sessrec.c + Makefile into SSH/Telnet build contexts as sessrec/."""
|
||||||
from decnet.services.registry import get_service
|
from decnet.services.registry import get_service
|
||||||
@@ -403,6 +436,7 @@ def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False,
|
|||||||
|
|
||||||
_sync_logging_helper(config)
|
_sync_logging_helper(config)
|
||||||
_sync_sessrec_sources(config)
|
_sync_sessrec_sources(config)
|
||||||
|
_sync_auth_helper_sources(config)
|
||||||
|
|
||||||
compose_path = write_compose(config, COMPOSE_FILE)
|
compose_path = write_compose(config, COMPOSE_FILE)
|
||||||
console.print(f"[bold cyan]Compose file written[/] → {compose_path}")
|
console.print(f"[bold cyan]Compose file written[/] → {compose_path}")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ ARG BASE_IMAGE=debian:bookworm-slim
|
|||||||
FROM debian:bookworm-slim AS auth-helper-build
|
FROM debian:bookworm-slim AS auth-helper-build
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends musl-tools \
|
RUN apt-get update && apt-get install -y --no-install-recommends musl-tools \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY auth-helper.c /tmp/auth-helper.c
|
COPY auth-helper/auth-helper.c /tmp/auth-helper.c
|
||||||
RUN musl-gcc -static -O2 -s -Wall -Wextra \
|
RUN musl-gcc -static -O2 -s -Wall -Wextra \
|
||||||
-o /auth-helper /tmp/auth-helper.c
|
-o /auth-helper /tmp/auth-helper.c
|
||||||
|
|
||||||
|
|||||||
173
decnet/templates/ssh/auth-helper/auth-helper.c
Normal file
173
decnet/templates/ssh/auth-helper/auth-helper.c
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
* 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).
|
||||||
|
*
|
||||||
|
* Two password fields ride in the SD-block:
|
||||||
|
* password RFC 5424-escaped ASCII-printable, '?' for non-printables.
|
||||||
|
* FTP-compatible; consumed by existing dashboard rendering.
|
||||||
|
* password_b64 base64 of the exact PAM_AUTHTOK bytes. Lossless.
|
||||||
|
* Preserves NUL/0xff/control bytes that the plain field
|
||||||
|
* would silently drop — useful fingerprinting signal.
|
||||||
|
*
|
||||||
|
* 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. */
|
||||||
|
char line[LINE_BUF];
|
||||||
|
int n = snprintf(line, sizeof(line),
|
||||||
|
"<134>1 %s %s auth-helper - auth_attempt "
|
||||||
|
"[relay@55555 username=\"%s\" password=\"%s\" "
|
||||||
|
"password_b64=\"%s\" src_ip=\"%s\"]\n",
|
||||||
|
tsbuf, host, 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;
|
||||||
|
}
|
||||||
@@ -1,8 +1,28 @@
|
|||||||
ARG BASE_IMAGE=debian:bookworm-slim
|
ARG BASE_IMAGE=debian:bookworm-slim
|
||||||
|
|
||||||
|
# ── Stage 1: build the static auth-helper credential-capture binary ──────────
|
||||||
|
# Same source the SSH template builds — generic over PAM service. Wired
|
||||||
|
# into /etc/pam.d/login below so every busybox-telnetd → /bin/login auth
|
||||||
|
# attempt is captured before pam_unix runs. Static + musl: ~38 KB ELF,
|
||||||
|
# zero libc version coupling, runs anywhere.
|
||||||
|
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 telnet decky image ───────────────────────────────────
|
||||||
FROM ${BASE_IMAGE}
|
FROM ${BASE_IMAGE}
|
||||||
|
|
||||||
|
# `login` (real Debian /bin/login, PAM-aware) replaces busybox's PAM-less
|
||||||
|
# login applet. libpam-modules ships pam_exec.so transitively. Both are
|
||||||
|
# needed for the auth-helper hook to fire — without them the PAM stack
|
||||||
|
# can't load pam_exec or call into a real PAM service.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
busybox-static \
|
busybox-static \
|
||||||
|
login \
|
||||||
|
libpam-modules \
|
||||||
rsyslog \
|
rsyslog \
|
||||||
procps \
|
procps \
|
||||||
net-tools \
|
net-tools \
|
||||||
@@ -41,6 +61,15 @@ RUN sed -i \
|
|||||||
-e 's|^auth,authpriv\.\*|#auth,authpriv.*|' \
|
-e 's|^auth,authpriv\.\*|#auth,authpriv.*|' \
|
||||||
/etc/rsyslog.conf
|
/etc/rsyslog.conf
|
||||||
|
|
||||||
|
# auth-helper: drop the static binary into /usr/sbin and wire pam_exec
|
||||||
|
# into the login PAM stack so every busybox-telnetd password attempt
|
||||||
|
# (success or fail) is captured before pam_unix runs. Same `optional`
|
||||||
|
# fail-open semantics as the SSH template.
|
||||||
|
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/login
|
||||||
|
|
||||||
# Realistic motd and issue banner
|
# Realistic motd and issue banner
|
||||||
RUN echo "Ubuntu 20.04.6 LTS" > /etc/issue.net && \
|
RUN echo "Ubuntu 20.04.6 LTS" > /etc/issue.net && \
|
||||||
echo "Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-150-generic x86_64)" > /etc/motd && \
|
echo "Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-150-generic x86_64)" > /etc/motd && \
|
||||||
|
|||||||
173
decnet/templates/telnet/auth-helper/auth-helper.c
Normal file
173
decnet/templates/telnet/auth-helper/auth-helper.c
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
* 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).
|
||||||
|
*
|
||||||
|
* Two password fields ride in the SD-block:
|
||||||
|
* password RFC 5424-escaped ASCII-printable, '?' for non-printables.
|
||||||
|
* FTP-compatible; consumed by existing dashboard rendering.
|
||||||
|
* password_b64 base64 of the exact PAM_AUTHTOK bytes. Lossless.
|
||||||
|
* Preserves NUL/0xff/control bytes that the plain field
|
||||||
|
* would silently drop — useful fingerprinting signal.
|
||||||
|
*
|
||||||
|
* 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. */
|
||||||
|
char line[LINE_BUF];
|
||||||
|
int n = snprintf(line, sizeof(line),
|
||||||
|
"<134>1 %s %s auth-helper - auth_attempt "
|
||||||
|
"[relay@55555 username=\"%s\" password=\"%s\" "
|
||||||
|
"password_b64=\"%s\" src_ip=\"%s\"]\n",
|
||||||
|
tsbuf, host, 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user