merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
190
decnet/templates/_shared/auth-helper/auth-helper.c
Normal file
190
decnet/templates/_shared/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;
|
||||
}
|
||||
132
decnet/templates/_shared/ntlmssp.py
Normal file
132
decnet/templates/_shared/ntlmssp.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""NTLMSSP Type 3 (Authenticate) message parser.
|
||||
|
||||
Standalone module shared between any honeypot template that wants to
|
||||
land NTLM credentials in the universal :class:`Credential` table.
|
||||
Currently consumed by the SMB and RDP-NLA templates.
|
||||
|
||||
The parser is intentionally narrow: only :func:`parse_type3` is public,
|
||||
and it reads a single Type 3 buffer (the bytes starting with the
|
||||
``NTLMSSP\\0`` signature). Callers handle SPNEGO unwrapping, SMB
|
||||
SessionSetup framing, RDP/CredSSP TSRequest parsing, etc.
|
||||
|
||||
Reference: MS-NLMP §2.2.1.3 (AUTHENTICATE_MESSAGE).
|
||||
|
||||
Cred-shape mapping for the universal Credential model:
|
||||
- ``principal`` = ``"DOMAIN\\username"`` when domain present, else
|
||||
bare username. Both decoded UTF-16-LE when NEGOTIATE_UNICODE is set
|
||||
in the message flags (it always is in modern clients).
|
||||
- ``secret_kind`` = ``"ntlmssp_v2"`` when the NtChallengeResponse is
|
||||
≥ 24 bytes (NTLMv2 carries variable-length blob ≥ 16+8 bytes),
|
||||
``"ntlmssp_v1"`` for the legacy 24-byte fixed response.
|
||||
- ``secret_b64`` = base64 of the entire NtChallengeResponse bytes.
|
||||
This is the canonical "hashcat -m 5600" (NTLMv2) or "-m 5500"
|
||||
(NTLMv1) input.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import struct
|
||||
from typing import Optional
|
||||
|
||||
NTLMSSP_SIG = b"NTLMSSP\x00"
|
||||
NEGOTIATE_UNICODE = 0x00000001
|
||||
|
||||
|
||||
def find_ntlmssp(buf: bytes) -> int:
|
||||
"""Return the offset of the NTLMSSP signature in ``buf`` or -1.
|
||||
|
||||
Useful for callers that have a SPNEGO-wrapped or SMB-embedded blob
|
||||
and want to skip straight to the inner Type 1/2/3 message without
|
||||
walking the outer ASN.1.
|
||||
"""
|
||||
return buf.find(NTLMSSP_SIG)
|
||||
|
||||
|
||||
def _read_field(buf: bytes, off: int) -> tuple[int, int, int]:
|
||||
"""Read an NTLMSSP field record: (Len, MaxLen, BufferOffset)."""
|
||||
if off + 8 > len(buf):
|
||||
return 0, 0, 0
|
||||
f_len, f_max, f_off = struct.unpack_from("<HHI", buf, off)
|
||||
return f_len, f_max, f_off
|
||||
|
||||
|
||||
def _slice(buf: bytes, off: int, length: int) -> bytes:
|
||||
end = off + length
|
||||
if off < 0 or end > len(buf) or length < 0:
|
||||
return b""
|
||||
return buf[off:end]
|
||||
|
||||
|
||||
def _decode_str(raw: bytes, unicode: bool) -> str:
|
||||
if unicode:
|
||||
return raw.decode("utf-16-le", errors="replace")
|
||||
return raw.decode("ascii", errors="replace")
|
||||
|
||||
|
||||
def parse_type3(blob: bytes) -> Optional[dict]:
|
||||
"""Parse an NTLMSSP Type 3 (AUTHENTICATE_MESSAGE) buffer.
|
||||
|
||||
Returns a dict with the universal credential SD shape ready to
|
||||
spread into a ``_log(...)`` call::
|
||||
|
||||
{
|
||||
"username": "alice", # service-specific identity
|
||||
"domain": "ACME", # domain (may be empty)
|
||||
"principal": "ACME\\\\alice", # hoisted column
|
||||
"secret_kind": "ntlmssp_v2", # or _v1
|
||||
"secret_printable": "<hex>", # NT response in hex
|
||||
"secret_b64": "<base64>", # NT response, lossless
|
||||
}
|
||||
|
||||
Returns ``None`` when ``blob`` is malformed or not a Type 3.
|
||||
"""
|
||||
if len(blob) < 32 or not blob.startswith(NTLMSSP_SIG):
|
||||
return None
|
||||
msg_type = struct.unpack_from("<I", blob, 8)[0]
|
||||
if msg_type != 3:
|
||||
return None
|
||||
|
||||
# Field record layout (all from MS-NLMP §2.2.1.3):
|
||||
# 12 LmChallengeResponseFields
|
||||
# 20 NtChallengeResponseFields
|
||||
# 28 DomainNameFields
|
||||
# 36 UserNameFields
|
||||
# 44 WorkstationFields
|
||||
# 52 EncryptedRandomSessionKeyFields
|
||||
# 60 NegotiateFlags
|
||||
nt_len, _, nt_off = _read_field(blob, 20)
|
||||
dom_len, _, dom_off = _read_field(blob, 28)
|
||||
user_len, _, user_off = _read_field(blob, 36)
|
||||
if len(blob) < 64:
|
||||
return None
|
||||
flags = struct.unpack_from("<I", blob, 60)[0]
|
||||
unicode = bool(flags & NEGOTIATE_UNICODE)
|
||||
|
||||
nt_response = _slice(blob, nt_off, nt_len)
|
||||
domain = _decode_str(_slice(blob, dom_off, dom_len), unicode)
|
||||
username = _decode_str(_slice(blob, user_off, user_len), unicode)
|
||||
|
||||
if not nt_response:
|
||||
# No NT response → anonymous bind or malformed; nothing to
|
||||
# treat as a credential.
|
||||
return None
|
||||
|
||||
# NTLMv2 NTChallengeResponseV2 has a 16-byte HMAC followed by a
|
||||
# variable-length blob (≥ 28 bytes total in practice). NTLMv1 is
|
||||
# exactly 24 bytes. Use length to discriminate; close enough for
|
||||
# cred-classification purposes (the bytes go on hashcat regardless).
|
||||
secret_kind = "ntlmssp_v1" if len(nt_response) == 24 else "ntlmssp_v2"
|
||||
|
||||
if domain:
|
||||
principal = f"{domain}\\{username}"
|
||||
else:
|
||||
principal = username or None
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"domain": domain,
|
||||
"principal": principal,
|
||||
"secret_kind": secret_kind,
|
||||
"secret_printable": nt_response.hex(),
|
||||
"secret_b64": base64.b64encode(nt_response).decode("ascii"),
|
||||
}
|
||||
28
decnet/templates/_shared/sessrec/Makefile
Normal file
28
decnet/templates/_shared/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/_shared/sessrec/sessrec.c
Normal file
564
decnet/templates/_shared/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;
|
||||
}
|
||||
28
decnet/templates/conpot/Dockerfile
Normal file
28
decnet/templates/conpot/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
ARG BASE_IMAGE=honeynet/conpot:latest
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
USER root
|
||||
|
||||
# Replace 5020 with 502 in all templates so Modbus binds on the standard port
|
||||
RUN find /opt /usr /etc /home -name "*.xml" -exec sed -i 's/<port>5020<\/port>/<port>502<\/port>/g' {} + 2>/dev/null || true
|
||||
RUN find /opt /usr /etc /home -name "*.xml" -exec sed -i 's/port="5020"/port="502"/g' {} + 2>/dev/null || true
|
||||
|
||||
# Install libcap and give the Python interpreter permission to bind ports < 1024
|
||||
RUN (apt-get update && apt-get install -y --no-install-recommends libcap2-bin 2>/dev/null) || (apk add --no-cache libcap 2>/dev/null) || true
|
||||
RUN find /home/conpot/.local/bin /usr /opt -type f -name 'python*' -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true
|
||||
|
||||
# Bridge conpot's own logger into syslog-relay's RFC 5424 syslog pipeline.
|
||||
# entrypoint.py is self-contained (inlines the formatter) because the
|
||||
# conpot base image runs Python 3.6, which cannot import the shared
|
||||
# syslog_bridge.py (that file uses 3.9+ / 3.10+ type syntax).
|
||||
COPY entrypoint.py /home/conpot/entrypoint.py
|
||||
RUN chown conpot:conpot /home/conpot/entrypoint.py \
|
||||
&& chmod +x /home/conpot/entrypoint.py
|
||||
|
||||
# The upstream image already runs as non-root 'conpot'.
|
||||
# We do NOT switch to a 'logrelay' user — doing so breaks pkg_resources
|
||||
# because conpot's eggs live under /home/conpot/.local and are only on
|
||||
# the Python path for that user.
|
||||
USER conpot
|
||||
|
||||
ENTRYPOINT ["/usr/bin/python3", "/home/conpot/entrypoint.py"]
|
||||
144
decnet/templates/conpot/entrypoint.py
Normal file
144
decnet/templates/conpot/entrypoint.py
Normal file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Entrypoint wrapper for the Conpot ICS/SCADA honeypot.
|
||||
|
||||
Launches conpot as a child process and bridges its log output into the
|
||||
syslog-relay structured syslog pipeline. Each line from conpot stdout/stderr
|
||||
is classified and emitted as an RFC 5424 syslog line so the host-side
|
||||
collector can ingest it alongside every other service.
|
||||
|
||||
Written to be compatible with Python 3.6 (the conpot base image version).
|
||||
"""
|
||||
from __future__ import print_function
|
||||
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# ── RFC 5424 inline formatter (Python 3.6-compatible) ─────────────────────────
|
||||
|
||||
_FACILITY_LOCAL0 = 16
|
||||
_SD_ID = "relay@55555"
|
||||
_NILVALUE = "-"
|
||||
|
||||
SEVERITY_INFO = 6
|
||||
SEVERITY_WARNING = 4
|
||||
SEVERITY_ERROR = 3
|
||||
|
||||
|
||||
def _sd_escape(value):
|
||||
return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]")
|
||||
|
||||
|
||||
def _syslog_line(event_type, severity=SEVERITY_INFO, **fields):
|
||||
pri = "<{}>".format(_FACILITY_LOCAL0 * 8 + severity)
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
host = NODE_NAME[:255]
|
||||
appname = "conpot"
|
||||
msgid = event_type[:32]
|
||||
|
||||
if fields:
|
||||
params = " ".join('{}="{}"'.format(k, _sd_escape(str(v))) for k, v in fields.items())
|
||||
sd = "[{} {}]".format(_SD_ID, params)
|
||||
else:
|
||||
sd = _NILVALUE
|
||||
|
||||
return "{pri}1 {ts} {host} {appname} {nil} {msgid} {sd}".format(
|
||||
pri=pri, ts=ts, host=host, appname=appname,
|
||||
nil=_NILVALUE, msgid=msgid, sd=sd,
|
||||
)
|
||||
|
||||
|
||||
def _log(event_type, severity=SEVERITY_INFO, **fields):
|
||||
print(_syslog_line(event_type, severity, **fields), flush=True)
|
||||
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "conpot-node")
|
||||
TEMPLATE = os.environ.get("CONPOT_TEMPLATE", "default")
|
||||
|
||||
_CONPOT_CMD = [
|
||||
"/home/conpot/.local/bin/conpot",
|
||||
"--template", TEMPLATE,
|
||||
"--logfile", "/var/log/conpot/conpot.log",
|
||||
"-f",
|
||||
"--temp_dir", "/tmp",
|
||||
]
|
||||
|
||||
# Grab the first routable IPv4 address from a log line
|
||||
_IP_RE = re.compile(r"\b((?!127\.)(?!0\.)(?!255\.)\d{1,3}(?:\.\d{1,3}){3})\b")
|
||||
|
||||
_REQUEST_RE = re.compile(
|
||||
r"request|recv|received|connect|session|query|command|"
|
||||
r"modbus|snmp|http|s7comm|bacnet|enip",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ERROR_RE = re.compile(r"error|exception|traceback|critical|fail", re.IGNORECASE)
|
||||
_WARN_RE = re.compile(r"warning|warn", re.IGNORECASE)
|
||||
_STARTUP_RE = re.compile(
|
||||
r"starting|started|listening|server|initializ|template|conpot",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
# ── Classifier ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _classify(raw):
|
||||
"""Return (event_type, severity, fields) for one conpot log line."""
|
||||
fields = {}
|
||||
|
||||
m = _IP_RE.search(raw)
|
||||
if m:
|
||||
fields["src"] = m.group(1)
|
||||
|
||||
fields["msg"] = raw[:300]
|
||||
|
||||
if _ERROR_RE.search(raw):
|
||||
return "error", SEVERITY_ERROR, fields
|
||||
if _WARN_RE.search(raw):
|
||||
return "warning", SEVERITY_WARNING, fields
|
||||
if _REQUEST_RE.search(raw):
|
||||
return "request", SEVERITY_INFO, fields
|
||||
if _STARTUP_RE.search(raw):
|
||||
return "startup", SEVERITY_INFO, fields
|
||||
return "log", SEVERITY_INFO, fields
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
_log("startup", msg="Conpot ICS honeypot starting (template={})".format(TEMPLATE))
|
||||
|
||||
proc = subprocess.Popen(
|
||||
_CONPOT_CMD,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
bufsize=1,
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
def _forward(sig, _frame):
|
||||
proc.send_signal(sig)
|
||||
|
||||
signal.signal(signal.SIGTERM, _forward)
|
||||
signal.signal(signal.SIGINT, _forward)
|
||||
|
||||
try:
|
||||
for raw_line in proc.stdout:
|
||||
line = raw_line.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
event_type, severity, fields = _classify(line)
|
||||
_log(event_type, severity, **fields)
|
||||
finally:
|
||||
proc.wait()
|
||||
_log("shutdown", msg="Conpot ICS honeypot stopped")
|
||||
sys.exit(proc.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
120
decnet/templates/conpot/instance_seed.py
Normal file
120
decnet/templates/conpot/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)
|
||||
261
decnet/templates/conpot/syslog_bridge.py
Normal file
261
decnet/templates/conpot/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
|
||||
22
decnet/templates/cowrie/Dockerfile
Normal file
22
decnet/templates/cowrie/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip python3-venv \
|
||||
libssl-dev libffi-dev \
|
||||
git authbind \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER logrelay
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
30
decnet/templates/cowrie/cowrie.cfg.j2
Normal file
30
decnet/templates/cowrie/cowrie.cfg.j2
Normal file
@@ -0,0 +1,30 @@
|
||||
[honeypot]
|
||||
hostname = {{ COWRIE_HOSTNAME | default('svr01') }}
|
||||
listen_endpoints = tcp:2222:interface=0.0.0.0
|
||||
kernel_version = {{ COWRIE_HONEYPOT_KERNEL_VERSION | default('5.15.0-76-generic') }}
|
||||
kernel_build_string = {{ COWRIE_HONEYPOT_KERNEL_BUILD_STRING | default('#83-Ubuntu SMP Thu Jun 15 19:16:32 UTC 2023') }}
|
||||
hardware_platform = {{ COWRIE_HONEYPOT_HARDWARE_PLATFORM | default('x86_64') }}
|
||||
|
||||
[ssh]
|
||||
enabled = true
|
||||
listen_endpoints = tcp:2222:interface=0.0.0.0
|
||||
version = {{ COWRIE_SSH_VERSION | default('SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.5') }}
|
||||
|
||||
{% if COWRIE_LOG_HOST is defined and COWRIE_LOG_HOST %}
|
||||
[output_jsonlog]
|
||||
enabled = true
|
||||
logfile = cowrie.json
|
||||
|
||||
[output_localsocket]
|
||||
enabled = false
|
||||
|
||||
# Forward JSON events to SIEM/aggregator
|
||||
[output_tcp]
|
||||
enabled = true
|
||||
host = {{ COWRIE_LOG_HOST }}
|
||||
port = {{ COWRIE_LOG_PORT | default('5140') }}
|
||||
{% else %}
|
||||
[output_jsonlog]
|
||||
enabled = true
|
||||
logfile = cowrie.json
|
||||
{% endif %}
|
||||
33
decnet/templates/cowrie/entrypoint.sh
Normal file
33
decnet/templates/cowrie/entrypoint.sh
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Render Jinja2 config template
|
||||
/home/cowrie/cowrie-env/bin/python3 - <<'EOF'
|
||||
import os
|
||||
from jinja2 import Template
|
||||
|
||||
with open("/home/cowrie/cowrie.cfg.j2") as f:
|
||||
tpl = Template(f.read())
|
||||
|
||||
rendered = tpl.render(**os.environ)
|
||||
|
||||
with open("/home/cowrie/cowrie-env/etc/cowrie.cfg", "w") as f:
|
||||
f.write(rendered)
|
||||
EOF
|
||||
|
||||
# Write userdb.txt if custom users were provided
|
||||
# Format: COWRIE_USERDB_ENTRIES=root:toor,admin:admin123
|
||||
if [ -n "${COWRIE_USERDB_ENTRIES}" ]; then
|
||||
USERDB="/home/cowrie/cowrie-env/etc/userdb.txt"
|
||||
: > "$USERDB"
|
||||
IFS=',' read -ra PAIRS <<< "${COWRIE_USERDB_ENTRIES}"
|
||||
for pair in "${PAIRS[@]}"; do
|
||||
user="${pair%%:*}"
|
||||
pass="${pair#*:}"
|
||||
uid=1000
|
||||
[ "$user" = "root" ] && uid=0
|
||||
echo "${user}:${uid}:${pass}" >> "$USERDB"
|
||||
done
|
||||
fi
|
||||
|
||||
exec authbind --deep /home/cowrie/cowrie-env/bin/twistd -n --pidfile= cowrie
|
||||
62
decnet/templates/cowrie/honeyfs/etc/group
Normal file
62
decnet/templates/cowrie/honeyfs/etc/group
Normal file
@@ -0,0 +1,62 @@
|
||||
root:x:0:
|
||||
daemon:x:1:
|
||||
bin:x:2:
|
||||
sys:x:3:
|
||||
adm:x:4:syslog,admin
|
||||
tty:x:5:
|
||||
disk:x:6:
|
||||
lp:x:7:
|
||||
mail:x:8:
|
||||
news:x:9:
|
||||
uucp:x:10:
|
||||
man:x:12:
|
||||
proxy:x:13:
|
||||
kmem:x:15:
|
||||
dialout:x:20:
|
||||
fax:x:21:
|
||||
voice:x:22:
|
||||
cdrom:x:24:admin
|
||||
floppy:x:25:
|
||||
tape:x:26:
|
||||
sudo:x:27:admin
|
||||
audio:x:29:
|
||||
dip:x:30:admin
|
||||
www-data:x:33:
|
||||
backup:x:34:
|
||||
operator:x:37:
|
||||
list:x:38:
|
||||
irc:x:39:
|
||||
src:x:40:
|
||||
gnats:x:41:
|
||||
shadow:x:42:
|
||||
utmp:x:43:
|
||||
video:x:44:
|
||||
sasl:x:45:
|
||||
plugdev:x:46:admin
|
||||
staff:x:50:
|
||||
games:x:60:
|
||||
users:x:100:
|
||||
nogroup:x:65534:
|
||||
systemd-journal:x:101:
|
||||
systemd-network:x:102:
|
||||
systemd-resolve:x:103:
|
||||
crontab:x:104:
|
||||
messagebus:x:105:
|
||||
systemd-timesync:x:106:
|
||||
input:x:107:
|
||||
sgx:x:108:
|
||||
kvm:x:109:
|
||||
render:x:110:
|
||||
syslog:x:110:
|
||||
tss:x:111:
|
||||
uuidd:x:112:
|
||||
tcpdump:x:113:
|
||||
ssl-cert:x:114:
|
||||
landscape:x:115:
|
||||
fwupd-refresh:x:116:
|
||||
usbmux:x:46:
|
||||
lxd:x:117:admin
|
||||
systemd-coredump:x:999:
|
||||
mysql:x:119:
|
||||
netdev:x:120:admin
|
||||
admin:x:1000:
|
||||
1
decnet/templates/cowrie/honeyfs/etc/hostname
Normal file
1
decnet/templates/cowrie/honeyfs/etc/hostname
Normal file
@@ -0,0 +1 @@
|
||||
NODE_NAME
|
||||
5
decnet/templates/cowrie/honeyfs/etc/hosts
Normal file
5
decnet/templates/cowrie/honeyfs/etc/hosts
Normal file
@@ -0,0 +1,5 @@
|
||||
127.0.0.1 localhost
|
||||
127.0.1.1 NODE_NAME
|
||||
::1 localhost ip6-localhost ip6-loopback
|
||||
ff02::1 ip6-allnodes
|
||||
ff02::2 ip6-allrouters
|
||||
2
decnet/templates/cowrie/honeyfs/etc/issue
Normal file
2
decnet/templates/cowrie/honeyfs/etc/issue
Normal file
@@ -0,0 +1,2 @@
|
||||
Ubuntu 22.04.3 LTS \n \l
|
||||
|
||||
1
decnet/templates/cowrie/honeyfs/etc/issue.net
Normal file
1
decnet/templates/cowrie/honeyfs/etc/issue.net
Normal file
@@ -0,0 +1 @@
|
||||
Ubuntu 22.04.3 LTS
|
||||
26
decnet/templates/cowrie/honeyfs/etc/motd
Normal file
26
decnet/templates/cowrie/honeyfs/etc/motd
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
* Documentation: https://help.ubuntu.com
|
||||
* Management: https://landscape.canonical.com
|
||||
* Support: https://ubuntu.com/advantage
|
||||
|
||||
System information as of Mon Jan 15 09:12:44 UTC 2024
|
||||
|
||||
System load: 0.08 Processes: 142
|
||||
Usage of /: 34.2% of 49.10GB Users logged in: 0
|
||||
Memory usage: 22% IPv4 address for eth0: 10.0.1.5
|
||||
Swap usage: 0%
|
||||
|
||||
* Strictly confined Kubernetes makes edge and IoT secure. Learn how MicroK8s
|
||||
just raised the bar for K8s security.
|
||||
|
||||
https://ubuntu.com/engage/secure-kubernetes-at-the-edge
|
||||
|
||||
Expanded Security Maintenance for Applications is not enabled.
|
||||
|
||||
0 updates can be applied immediately.
|
||||
|
||||
Enable ESM Apps to receive additional future security updates.
|
||||
See https://ubuntu.com/esm or run: sudo pro status
|
||||
|
||||
|
||||
Last login: Sun Jan 14 23:45:01 2024 from 10.0.0.1
|
||||
12
decnet/templates/cowrie/honeyfs/etc/os-release
Normal file
12
decnet/templates/cowrie/honeyfs/etc/os-release
Normal file
@@ -0,0 +1,12 @@
|
||||
PRETTY_NAME="Ubuntu 22.04.3 LTS"
|
||||
NAME="Ubuntu"
|
||||
VERSION_ID="22.04"
|
||||
VERSION="22.04.3 LTS (Jammy Jellyfish)"
|
||||
VERSION_CODENAME=jammy
|
||||
ID=ubuntu
|
||||
ID_LIKE=debian
|
||||
HOME_URL="https://www.ubuntu.com/"
|
||||
SUPPORT_URL="https://help.ubuntu.com/"
|
||||
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
|
||||
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
|
||||
UBUNTU_CODENAME=jammy
|
||||
36
decnet/templates/cowrie/honeyfs/etc/passwd
Normal file
36
decnet/templates/cowrie/honeyfs/etc/passwd
Normal file
@@ -0,0 +1,36 @@
|
||||
root:x:0:0:root:/root:/bin/bash
|
||||
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
|
||||
bin:x:2:2:bin:/bin:/usr/sbin/nologin
|
||||
sys:x:3:3:sys:/dev:/usr/sbin/nologin
|
||||
sync:x:4:65534:sync:/bin:/bin/sync
|
||||
games:x:5:60:games:/usr/games:/usr/sbin/nologin
|
||||
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
|
||||
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
|
||||
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
|
||||
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
|
||||
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
|
||||
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
|
||||
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
|
||||
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
|
||||
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
|
||||
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
|
||||
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
|
||||
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
|
||||
systemd-network:x:100:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
|
||||
systemd-resolve:x:101:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
|
||||
messagebus:x:102:105::/nonexistent:/usr/sbin/nologin
|
||||
systemd-timesync:x:103:106:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
|
||||
syslog:x:104:110::/home/syslog:/usr/sbin/nologin
|
||||
_apt:x:105:65534::/nonexistent:/usr/sbin/nologin
|
||||
tss:x:106:111:TPM software stack,,,:/var/lib/tpm:/bin/false
|
||||
uuidd:x:107:112::/run/uuidd:/usr/sbin/nologin
|
||||
tcpdump:x:108:113::/nonexistent:/usr/sbin/nologin
|
||||
landscape:x:109:115::/var/lib/landscape:/usr/sbin/nologin
|
||||
pollinate:x:110:1::/var/cache/pollinate:/bin/false
|
||||
fwupd-refresh:x:111:116:fwupd-refresh user,,,:/run/systemd:/usr/sbin/nologin
|
||||
usbmux:x:112:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
|
||||
sshd:x:113:65534::/run/sshd:/usr/sbin/nologin
|
||||
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
|
||||
lxd:x:998:100::/var/snap/lxd/common/lxd:/bin/false
|
||||
mysql:x:114:119:MySQL Server,,,:/nonexistent:/bin/false
|
||||
admin:x:1000:1000:Admin User,,,:/home/admin:/bin/bash
|
||||
4
decnet/templates/cowrie/honeyfs/etc/resolv.conf
Normal file
4
decnet/templates/cowrie/honeyfs/etc/resolv.conf
Normal file
@@ -0,0 +1,4 @@
|
||||
# This file is managed by man:systemd-resolved(8). Do not edit.
|
||||
nameserver 8.8.8.8
|
||||
nameserver 8.8.4.4
|
||||
search company.internal
|
||||
36
decnet/templates/cowrie/honeyfs/etc/shadow
Normal file
36
decnet/templates/cowrie/honeyfs/etc/shadow
Normal file
@@ -0,0 +1,36 @@
|
||||
root:$6$rounds=4096$randomsalt$hashed_root_password:19000:0:99999:7:::
|
||||
daemon:*:19000:0:99999:7:::
|
||||
bin:*:19000:0:99999:7:::
|
||||
sys:*:19000:0:99999:7:::
|
||||
sync:*:19000:0:99999:7:::
|
||||
games:*:19000:0:99999:7:::
|
||||
man:*:19000:0:99999:7:::
|
||||
lp:*:19000:0:99999:7:::
|
||||
mail:*:19000:0:99999:7:::
|
||||
news:*:19000:0:99999:7:::
|
||||
uucp:*:19000:0:99999:7:::
|
||||
proxy:*:19000:0:99999:7:::
|
||||
www-data:*:19000:0:99999:7:::
|
||||
backup:*:19000:0:99999:7:::
|
||||
list:*:19000:0:99999:7:::
|
||||
irc:*:19000:0:99999:7:::
|
||||
gnats:*:19000:0:99999:7:::
|
||||
nobody:*:19000:0:99999:7:::
|
||||
systemd-network:*:19000:0:99999:7:::
|
||||
systemd-resolve:*:19000:0:99999:7:::
|
||||
messagebus:*:19000:0:99999:7:::
|
||||
systemd-timesync:*:19000:0:99999:7:::
|
||||
syslog:*:19000:0:99999:7:::
|
||||
_apt:*:19000:0:99999:7:::
|
||||
tss:*:19000:0:99999:7:::
|
||||
uuidd:*:19000:0:99999:7:::
|
||||
tcpdump:*:19000:0:99999:7:::
|
||||
landscape:*:19000:0:99999:7:::
|
||||
pollinate:*:19000:0:99999:7:::
|
||||
fwupd-refresh:*:19000:0:99999:7:::
|
||||
usbmux:*:19000:0:99999:7:::
|
||||
sshd:*:19000:0:99999:7:::
|
||||
systemd-coredump:!!:19000::::::
|
||||
lxd:!:19000::::::
|
||||
mysql:!:19000:0:99999:7:::
|
||||
admin:$6$rounds=4096$xyz123$hashed_admin_password:19000:0:99999:7:::
|
||||
14
decnet/templates/cowrie/honeyfs/home/admin/.aws/credentials
Normal file
14
decnet/templates/cowrie/honeyfs/home/admin/.aws/credentials
Normal file
@@ -0,0 +1,14 @@
|
||||
[default]
|
||||
aws_access_key_id = AKIAIOSFODNN7EXAMPLE
|
||||
aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
region = us-east-1
|
||||
|
||||
[production]
|
||||
aws_access_key_id = AKIAI44QH8DHBEXAMPLE
|
||||
aws_secret_access_key = je7MtGbClwBF/2Zp9Utk/h3yCo8nvbEXAMPLEKEY
|
||||
region = us-east-1
|
||||
|
||||
[backup-role]
|
||||
aws_access_key_id = AKIAIOSFODNN7BACKUP1
|
||||
aws_secret_access_key = 9drTJvcXLB89EXAMPLEKEY/bPxRfiCYBACKUPKEY
|
||||
region = eu-west-2
|
||||
33
decnet/templates/cowrie/honeyfs/home/admin/.bash_history
Normal file
33
decnet/templates/cowrie/honeyfs/home/admin/.bash_history
Normal file
@@ -0,0 +1,33 @@
|
||||
ls -la
|
||||
cd /var/www/html
|
||||
git status
|
||||
git pull origin main
|
||||
sudo systemctl restart nginx
|
||||
sudo systemctl status nginx
|
||||
df -h
|
||||
free -m
|
||||
top
|
||||
ps aux | grep nginx
|
||||
aws s3 ls
|
||||
aws s3 ls s3://company-prod-backups
|
||||
aws s3 cp /var/www/html/backup.tar.gz s3://company-prod-backups/
|
||||
aws ec2 describe-instances --region us-east-1
|
||||
kubectl get pods -n production
|
||||
kubectl get services -n production
|
||||
kubectl describe pod api-deployment-7d4b9c5f6-xk2pz -n production
|
||||
docker ps
|
||||
docker images
|
||||
docker-compose up -d
|
||||
mysql -u admin -pSup3rS3cr3t! -h 10.0.1.5 production
|
||||
cat /etc/mysql/my.cnf
|
||||
tail -f /var/log/nginx/access.log
|
||||
tail -f /var/log/auth.log
|
||||
ssh root@10.0.1.10
|
||||
scp admin@10.0.1.20:/home/admin/.aws/credentials /tmp/
|
||||
cat ~/.aws/credentials
|
||||
vim ~/.aws/credentials
|
||||
sudo crontab -l
|
||||
ls /opt/app/
|
||||
cd /opt/app && npm run build
|
||||
git log --oneline -20
|
||||
history
|
||||
@@ -0,0 +1,2 @@
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7+xamplekeyforadminuser+xamplekeyforadminuser+xamplekeyforadminuser+xamplekeyforadminuser+xamplekeyforadminuser+xamplekeyforadminuser+xamplekeyforadminuser+xamplekeyforadminuser+xamplekeyforadminuser+xamplekeyforadminuser+xamplekeyforadminuser+xamplekeyforadminuser+xamplekey admin@workstation
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDbackupkeyfordeploymentpipeline+backupkeyfordeploymentpipeline+backupkeyfordeploymentpipeline+backupkeyfordeploymentpipeline+backupkeyfordeploymentpipeline+backupkeyfordeploymentpipeline+backupkeyfordeploymentpipeline+backupkeyfordeploymentpipeline+backupkeyfordeploymentpipeline+backupkeyfordeploymentpipeline deploy@ci-runner
|
||||
22
decnet/templates/cowrie/honeyfs/root/.bash_history
Normal file
22
decnet/templates/cowrie/honeyfs/root/.bash_history
Normal file
@@ -0,0 +1,22 @@
|
||||
whoami
|
||||
id
|
||||
uname -a
|
||||
cat /etc/passwd
|
||||
cat /etc/shadow
|
||||
ls /home
|
||||
ls /home/admin
|
||||
cat /home/admin/.bash_history
|
||||
cat /home/admin/.aws/credentials
|
||||
find / -name "*.pem" 2>/dev/null
|
||||
find / -name "id_rsa" 2>/dev/null
|
||||
find / -name "*.key" 2>/dev/null
|
||||
netstat -tunlp
|
||||
ss -tunlp
|
||||
iptables -L
|
||||
cat /etc/crontab
|
||||
crontab -l
|
||||
ps aux
|
||||
systemctl list-units
|
||||
cat /etc/mysql/my.cnf
|
||||
mysql -u root -p
|
||||
history -c
|
||||
12
decnet/templates/cowrie/honeyfs/var/log/auth.log
Normal file
12
decnet/templates/cowrie/honeyfs/var/log/auth.log
Normal file
@@ -0,0 +1,12 @@
|
||||
Jan 14 23:31:04 NODE_NAME sshd[1832]: Accepted publickey for admin from 10.0.0.1 port 54321 ssh2: RSA SHA256:xAmPlEkEyHaSh1234567890abcdefghijklmnop
|
||||
Jan 14 23:31:04 NODE_NAME sshd[1832]: pam_unix(sshd:session): session opened for user admin by (uid=0)
|
||||
Jan 14 23:31:46 NODE_NAME sudo[1901]: admin : TTY=pts/0 ; PWD=/home/admin ; USER=root ; COMMAND=/usr/bin/systemctl restart nginx
|
||||
Jan 14 23:31:46 NODE_NAME sudo[1901]: pam_unix(sudo:session): session opened for user root by admin(uid=0)
|
||||
Jan 14 23:31:47 NODE_NAME sudo[1901]: pam_unix(sudo:session): session closed for user root
|
||||
Jan 14 23:45:01 NODE_NAME sshd[1832]: pam_unix(sshd:session): session closed for user admin
|
||||
Jan 15 00:02:14 NODE_NAME sshd[2104]: Failed password for invalid user oracle from 185.220.101.47 port 38291 ssh2
|
||||
Jan 15 00:02:16 NODE_NAME sshd[2106]: Failed password for invalid user postgres from 185.220.101.47 port 38295 ssh2
|
||||
Jan 15 00:02:19 NODE_NAME sshd[2108]: Failed password for root from 185.220.101.47 port 38301 ssh2
|
||||
Jan 15 00:02:19 NODE_NAME sshd[2108]: error: maximum authentication attempts exceeded for root from 185.220.101.47 port 38301 ssh2 [preauth]
|
||||
Jan 15 09:12:44 NODE_NAME sshd[2891]: Accepted password for admin from 10.0.0.5 port 51243 ssh2
|
||||
Jan 15 09:12:44 NODE_NAME sshd[2891]: pam_unix(sshd:session): session opened for user admin by (uid=0)
|
||||
26
decnet/templates/docker_api/Dockerfile
Normal file
26
decnet/templates/docker_api/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN pip3 install --no-cache-dir flask
|
||||
|
||||
COPY syslog_bridge.py /opt/syslog_bridge.py
|
||||
COPY server.py /opt/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 2375 2376
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER logrelay
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
3
decnet/templates/docker_api/entrypoint.sh
Normal file
3
decnet/templates/docker_api/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
exec python3 /opt/server.py
|
||||
120
decnet/templates/docker_api/instance_seed.py
Normal file
120
decnet/templates/docker_api/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)
|
||||
124
decnet/templates/docker_api/server.py
Normal file
124
decnet/templates/docker_api/server.py
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Docker APIserver.
|
||||
Serves a fake Docker REST API on port 2375. Responds to common recon
|
||||
endpoints (/version, /info, /containers/json, /images/json) with plausible
|
||||
but fake data. Logs all requests as JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from flask import Flask, request
|
||||
from syslog_bridge import (
|
||||
classify_authorization,
|
||||
forward_syslog,
|
||||
syslog_line,
|
||||
write_syslog_file,
|
||||
)
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "docker-host")
|
||||
SERVICE_NAME = "docker_api"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
_VERSION = {
|
||||
"Version": "24.0.5",
|
||||
"ApiVersion": "1.43",
|
||||
"MinAPIVersion": "1.12",
|
||||
"GitCommit": "ced0996",
|
||||
"GoVersion": "go1.20.6",
|
||||
"Os": "linux",
|
||||
"Arch": "amd64",
|
||||
"KernelVersion": "5.15.0-76-generic",
|
||||
}
|
||||
|
||||
_INFO = {
|
||||
"ID": "FAKE:FAKE:FAKE:FAKE",
|
||||
"Containers": 3,
|
||||
"ContainersRunning": 3,
|
||||
"Images": 7,
|
||||
"Driver": "overlay2",
|
||||
"MemoryLimit": True,
|
||||
"SwapLimit": True,
|
||||
"KernelMemory": False,
|
||||
"Name": NODE_NAME,
|
||||
"DockerRootDir": "/var/lib/docker",
|
||||
"HttpProxy": "",
|
||||
"HttpsProxy": "",
|
||||
"NoProxy": "",
|
||||
"ServerVersion": "24.0.5",
|
||||
}
|
||||
|
||||
_CONTAINERS = [
|
||||
{
|
||||
"Id": "a1b2c3d4e5f6",
|
||||
"Names": ["/webapp"],
|
||||
"Image": "nginx:latest",
|
||||
"State": "running",
|
||||
"Status": "Up 3 days",
|
||||
"Ports": [{"IP": "0.0.0.0", "PrivatePort": 80, "PublicPort": 8080, "Type": "tcp"}], # nosec B104
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
@app.before_request
|
||||
def log_request():
|
||||
cred = classify_authorization(request.headers.get("Authorization"))
|
||||
_log(
|
||||
"request",
|
||||
method=request.method,
|
||||
path=request.path,
|
||||
remote_addr=request.remote_addr,
|
||||
headers=json.dumps(dict(request.headers)),
|
||||
body=request.get_data(as_text=True)[:512],
|
||||
**(cred or {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/version")
|
||||
@app.route("/<ver>/version")
|
||||
def version(ver=None):
|
||||
return app.response_class(json.dumps(_VERSION), mimetype="application/json")
|
||||
|
||||
|
||||
@app.route("/info")
|
||||
@app.route("/<ver>/info")
|
||||
def info(ver=None):
|
||||
return app.response_class(json.dumps(_INFO), mimetype="application/json")
|
||||
|
||||
|
||||
@app.route("/containers/json")
|
||||
@app.route("/<ver>/containers/json")
|
||||
def containers(ver=None):
|
||||
return app.response_class(json.dumps(_CONTAINERS), mimetype="application/json")
|
||||
|
||||
|
||||
@app.route("/images/json")
|
||||
@app.route("/<ver>/images/json")
|
||||
def images(ver=None):
|
||||
return app.response_class(json.dumps([]), mimetype="application/json")
|
||||
|
||||
|
||||
@app.route("/", defaults={"path": ""})
|
||||
@app.route("/<path:path>", methods=["GET", "POST", "PUT", "DELETE"])
|
||||
def catch_all(path):
|
||||
return app.response_class(
|
||||
json.dumps({"message": "page not found", "response": 404}),
|
||||
status=404,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_log("startup", msg=f"Docker API server starting as {NODE_NAME}")
|
||||
app.run(host="0.0.0.0", port=2375, debug=False) # nosec B104
|
||||
261
decnet/templates/docker_api/syslog_bridge.py
Normal file
261
decnet/templates/docker_api/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
|
||||
24
decnet/templates/elasticsearch/Dockerfile
Normal file
24
decnet/templates/elasticsearch/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY syslog_bridge.py /opt/syslog_bridge.py
|
||||
COPY instance_seed.py /opt/instance_seed.py
|
||||
COPY server.py /opt/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 9200
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER logrelay
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
3
decnet/templates/elasticsearch/entrypoint.sh
Normal file
3
decnet/templates/elasticsearch/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
exec python3 /opt/server.py
|
||||
120
decnet/templates/elasticsearch/instance_seed.py
Normal file
120
decnet/templates/elasticsearch/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)
|
||||
195
decnet/templates/elasticsearch/server.py
Normal file
195
decnet/templates/elasticsearch/server.py
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Elasticsearch server — presents a convincing ES 7.x HTTP API on port 9200.
|
||||
Logs all requests (especially recon probes like /_cat/, /_cluster/, /_nodes/)
|
||||
as JSON. Designed to attract automated scanners and credential stuffers.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import (
|
||||
classify_authorization,
|
||||
forward_syslog,
|
||||
syslog_line,
|
||||
write_syslog_file,
|
||||
)
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "esserver")
|
||||
SERVICE_NAME = "elasticsearch"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
|
||||
# Real ES cluster/node UUIDs are 22-char base64 (16 random bytes,
|
||||
# URL-safe, unpadded). Generate deterministically per instance.
|
||||
def _es_uuid(namespace: str) -> str:
|
||||
raw = _seed.random_bytes(16, namespace)
|
||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
|
||||
|
||||
_CLUSTER_UUID = _es_uuid("es-cluster")
|
||||
_NODE_UUID = _es_uuid("es-node")
|
||||
_CLUSTER_NAME = os.environ.get("ES_CLUSTER_NAME") or _seed.pick([
|
||||
"elasticsearch", "logs", "search-prod", "metrics", "siem-cluster",
|
||||
"docker-cluster",
|
||||
])
|
||||
|
||||
# Realistic (version, build_hash, build_date, lucene_version) tuples taken
|
||||
# from real ES release metadata. Build-hashes change per release; pairing
|
||||
# them correctly is what makes the version check survive a real client
|
||||
# reading /_nodes and comparing against its known-versions table.
|
||||
_ES_RELEASES = [
|
||||
("7.17.9", "ef48222227ee6b9e70e502f0f0daa52435ee634d", "2023-01-31T05:34:43.305517834Z", "8.11.1"),
|
||||
("7.17.14", "774e3bfa4d52e2834e4d9fdbb4b462fa1ba1cc5a", "2023-10-05T12:16:58.531639647Z", "8.11.1"),
|
||||
("7.17.18", "8682172c2130b9a411b1bd1ff37c2f4f15f04c7b", "2024-02-02T16:43:31.000Z", "8.11.1"),
|
||||
("8.10.4", "b4a62ac808e886ff032700c391f45f1408b2538c", "2023-10-11T22:04:35.506990650Z", "9.7.0"),
|
||||
("8.11.4", "49b9bd5ec73c11d7b49dbd6ffc70b9ea2cdb67d0", "2023-12-19T16:57:03.000Z", "9.8.0"),
|
||||
("8.12.2", "48a287ab9497e852de30327444b0809e55d46466", "2024-02-15T15:25:20.000Z", "9.9.2"),
|
||||
("8.13.4", "da95df118650b55a500dcc181889ac35c6d8da7c", "2024-05-07T15:39:32.000Z", "9.10.0"),
|
||||
]
|
||||
_ES_VERSION, _ES_BUILD_HASH, _ES_BUILD_DATE, _ES_LUCENE = _seed.pick(_ES_RELEASES)
|
||||
|
||||
# Wire-compat rules in ES are hard-coded per major: pick the right ones.
|
||||
if _ES_VERSION.startswith("8."):
|
||||
_MIN_WIRE = "7.17.0"
|
||||
_MIN_INDEX = "7.0.0"
|
||||
else:
|
||||
_MIN_WIRE = "6.8.0"
|
||||
_MIN_INDEX = "6.0.0-beta1"
|
||||
|
||||
# Per-instance cluster size — shapes /_cat/nodes + /_cluster/health output.
|
||||
_CLUSTER_NODES = _seed.rng.choice([1, 1, 3, 3, 3, 5, 5, 7])
|
||||
|
||||
|
||||
_ROOT_RESPONSE = {
|
||||
"name": NODE_NAME,
|
||||
"cluster_name": _CLUSTER_NAME,
|
||||
"cluster_uuid": _CLUSTER_UUID,
|
||||
"version": {
|
||||
"number": _ES_VERSION,
|
||||
"build_flavor": "default",
|
||||
"build_type": "docker",
|
||||
"build_hash": _ES_BUILD_HASH,
|
||||
"build_date": _ES_BUILD_DATE,
|
||||
"build_snapshot": False,
|
||||
"lucene_version": _ES_LUCENE,
|
||||
"minimum_wire_compatibility_version": _MIN_WIRE,
|
||||
"minimum_index_compatibility_version": _MIN_INDEX,
|
||||
},
|
||||
"tagline": "You Know, for Search",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
class ESHandler(BaseHTTPRequestHandler):
|
||||
server_version = "elasticsearch"
|
||||
sys_version = ""
|
||||
|
||||
def _send_json(self, code: int, data: dict) -> None:
|
||||
body = json.dumps(data).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=UTF-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("X-elastic-product", "Elasticsearch")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _read_body(self) -> str:
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
return self.rfile.read(length).decode(errors="replace") if length else ""
|
||||
|
||||
def _cred_fields(self) -> dict:
|
||||
"""Universal cred shape from this request's Authorization header,
|
||||
or empty dict when absent / unrecognized."""
|
||||
return classify_authorization(self.headers.get("Authorization")) or {}
|
||||
|
||||
def do_GET(self):
|
||||
src = self.client_address[0]
|
||||
path = self.path.split("?")[0]
|
||||
|
||||
if path in ("/", ""):
|
||||
_log("root_probe", src=src, method="GET", path=self.path, **self._cred_fields())
|
||||
self._send_json(200, _ROOT_RESPONSE)
|
||||
elif path.startswith("/_cat/"):
|
||||
_log("cat_api", src=src, method="GET", path=self.path, **self._cred_fields())
|
||||
self._send_json(200, [])
|
||||
elif path.startswith("/_cluster/"):
|
||||
_log("cluster_recon", src=src, method="GET", path=self.path, **self._cred_fields())
|
||||
self._send_json(200, {
|
||||
"cluster_name": _CLUSTER_NAME,
|
||||
"cluster_uuid": _CLUSTER_UUID,
|
||||
"status": _seed.pick(["green", "green", "green", "yellow"]),
|
||||
"timed_out": False,
|
||||
"number_of_nodes": _CLUSTER_NODES,
|
||||
"number_of_data_nodes": _CLUSTER_NODES,
|
||||
"active_primary_shards": _seed.rng.randint(5, 180),
|
||||
"active_shards": _seed.rng.randint(10, 360),
|
||||
"relocating_shards": 0,
|
||||
"initializing_shards": 0,
|
||||
"unassigned_shards": 0,
|
||||
"active_shards_percent_as_number": 100.0,
|
||||
})
|
||||
elif path.startswith("/_nodes"):
|
||||
_log("nodes_recon", src=src, method="GET", path=self.path, **self._cred_fields())
|
||||
self._send_json(200, {
|
||||
"_nodes": {"total": _CLUSTER_NODES, "successful": _CLUSTER_NODES, "failed": 0},
|
||||
"cluster_name": _CLUSTER_NAME,
|
||||
"nodes": {_NODE_UUID: {"name": NODE_NAME, "version": _ES_VERSION,
|
||||
"build_hash": _ES_BUILD_HASH}},
|
||||
})
|
||||
elif path.startswith("/_security/") or path.startswith("/_xpack/"):
|
||||
_log("security_probe", src=src, method="GET", path=self.path, **self._cred_fields())
|
||||
self._send_json(200, {"enabled": True, "available": True})
|
||||
else:
|
||||
_log("request", src=src, method="GET", path=self.path, **self._cred_fields())
|
||||
self._send_json(404, {"error": {"root_cause": [{"type": "index_not_found_exception",
|
||||
"reason": "no such index"}]}})
|
||||
|
||||
def do_POST(self):
|
||||
src = self.client_address[0]
|
||||
body = self._read_body()
|
||||
path = self.path.split("?")[0]
|
||||
_log("post_request", src=src, method="POST", path=self.path,
|
||||
body_preview=body[:300], user_agent=self.headers.get("User-Agent", ""),
|
||||
**self._cred_fields())
|
||||
if "_search" in path or "_bulk" in path:
|
||||
self._send_json(200, {"took": 1, "timed_out": False, "hits": {"total": {"value": 0}, "hits": []}})
|
||||
else:
|
||||
self._send_json(200, {"result": "created", "_id": "1", "_index": "server"})
|
||||
|
||||
def do_PUT(self):
|
||||
src = self.client_address[0]
|
||||
body = self._read_body()
|
||||
_log("put_request", src=src, method="PUT", path=self.path,
|
||||
body_preview=body[:300], **self._cred_fields())
|
||||
self._send_json(200, {"acknowledged": True})
|
||||
|
||||
def do_DELETE(self):
|
||||
src = self.client_address[0]
|
||||
_log("delete_request", src=src, method="DELETE", path=self.path,
|
||||
**self._cred_fields())
|
||||
self._send_json(200, {"acknowledged": True})
|
||||
|
||||
def do_HEAD(self):
|
||||
src = self.client_address[0]
|
||||
_log("head_request", src=src, method="HEAD", path=self.path,
|
||||
**self._cred_fields())
|
||||
self._send_json(200, {})
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass # suppress default HTTP server logging
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_log("startup", msg=f"Elasticsearch server starting as {NODE_NAME}")
|
||||
server = HTTPServer(("0.0.0.0", 9200), ESHandler) # nosec B104
|
||||
server.serve_forever()
|
||||
261
decnet/templates/elasticsearch/syslog_bridge.py
Normal file
261
decnet/templates/elasticsearch/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
|
||||
27
decnet/templates/ftp/Dockerfile
Normal file
27
decnet/templates/ftp/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN pip3 install --no-cache-dir twisted jinja2
|
||||
|
||||
COPY syslog_bridge.py /opt/syslog_bridge.py
|
||||
COPY instance_seed.py /opt/instance_seed.py
|
||||
COPY server.py /opt/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 21
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER logrelay
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
3
decnet/templates/ftp/entrypoint.sh
Normal file
3
decnet/templates/ftp/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
exec python3 /opt/server.py
|
||||
120
decnet/templates/ftp/instance_seed.py
Normal file
120
decnet/templates/ftp/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)
|
||||
144
decnet/templates/ftp/server.py
Normal file
144
decnet/templates/ftp/server.py
Normal file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FTP server using Twisted's FTP server infrastructure.
|
||||
Accepts any credentials, logs all commands and file requests,
|
||||
forwards events as JSON to LOG_TARGET if set.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from twisted.internet import defer, reactor
|
||||
from twisted.protocols.ftp import FTP, FTPFactory, FTPAnonymousShell
|
||||
from twisted.python.filepath import FilePath
|
||||
from twisted.python import log as twisted_log
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import (
|
||||
encode_secret,
|
||||
forward_syslog,
|
||||
syslog_line,
|
||||
write_syslog_file,
|
||||
)
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "ftpserver")
|
||||
SERVICE_NAME = "ftp"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
PORT = int(os.environ.get("PORT", "21"))
|
||||
|
||||
# Per-instance daemon identity. Fleet-wide "vsFTPd 3.0.3" is an instant
|
||||
# fingerprint of an unmaintained honeypot — real shops run a mix.
|
||||
_FTP_BANNER_CHOICES = [
|
||||
"220 (vsFTPd 3.0.3)",
|
||||
"220 (vsFTPd 3.0.5)",
|
||||
"220 ProFTPD 1.3.7a Server ready.",
|
||||
"220 ProFTPD 1.3.6 Server ready.",
|
||||
"220 Pure-FTPd Service ready.",
|
||||
]
|
||||
BANNER = os.environ.get("FTP_BANNER") or _seed.pick(_FTP_BANNER_CHOICES)
|
||||
|
||||
# Accept approximately this fraction of logins. Real anon-accessible
|
||||
# servers succeed often; credential-harvesting scanners hitting every
|
||||
# possible user/pass pair should still see plausible failures.
|
||||
_LOGIN_SUCCESS_RATE = float(os.environ.get("FTP_LOGIN_SUCCESS_RATE", "0.9"))
|
||||
|
||||
# Optional override — if set to "never", ALL logins fail (realistic for a
|
||||
# server with anonymous disabled). Handy for producing server diversity
|
||||
# across the fleet.
|
||||
_LOGIN_MODE = os.environ.get("FTP_LOGIN_MODE", "").strip().lower()
|
||||
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
def _setup_bait_fs() -> str:
|
||||
"""Generate a per-instance bait filesystem.
|
||||
|
||||
No shared paths across deckies (/tmp/ftp_bait was identical on every
|
||||
host), no tell-tale 'super_secret_admin_pw' strings. Filenames, byte
|
||||
counts, and inline values are all derived from the per-decky seed, so
|
||||
two honeypots never serve byte-identical files yet each stays stable
|
||||
across restarts."""
|
||||
bait_dir = Path(f"/tmp/ftp-{_seed.instance_hex(6, 'ftp-bait-dir')}")
|
||||
bait_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
company = _seed.pick(["acme", "contoso", "northwind", "initech", "globex", "hooli"])
|
||||
env = _seed.pick(["prod", "stage", "backup", "archive"])
|
||||
year = _seed.rng.randint(2022, 2024)
|
||||
month = _seed.rng.randint(1, 12)
|
||||
|
||||
# Realistic-looking rotating backups. Sizes vary per instance.
|
||||
for idx in range(_seed.rng.randint(2, 5)):
|
||||
tag = f"{year}{month:02d}{_seed.rng.randint(1, 28):02d}"
|
||||
size = _seed.rng.randint(2048, 32768)
|
||||
(bait_dir / f"{company}-{env}-{tag}.tar.gz").write_bytes(
|
||||
b"\x1f\x8b\x08\x00" + _seed.random_bytes(size - 4, f"tar-{idx}")
|
||||
)
|
||||
|
||||
# A plausible README that looks like legacy ops notes, NOT a credential
|
||||
# dump. No "password = ..." strings — those are a dead giveaway.
|
||||
(bait_dir / "README.txt").write_text(
|
||||
f"{company} {env} drop area\n"
|
||||
f"Rotation: keep last 14, nightly rsync from db{_seed.rng.randint(1,9)}.{env}\n"
|
||||
f"Contact: ops-{env}@{company}.internal\n"
|
||||
)
|
||||
(bait_dir / ".htaccess").write_text("Options -Indexes\n")
|
||||
|
||||
return str(bait_dir)
|
||||
|
||||
|
||||
_BAIT_PATH = _setup_bait_fs()
|
||||
|
||||
|
||||
class ServerFTP(FTP):
|
||||
def connectionMade(self):
|
||||
peer = self.transport.getPeer()
|
||||
_log("connection", src_ip=peer.host, src_port=peer.port)
|
||||
super().connectionMade()
|
||||
|
||||
def ftp_USER(self, username):
|
||||
self._server_user = username
|
||||
_log("user", username=username)
|
||||
return super().ftp_USER(username)
|
||||
|
||||
def ftp_PASS(self, password):
|
||||
_u = getattr(self, "_server_user", "?")
|
||||
_log("auth_attempt", username=_u, principal=_u, **encode_secret(password))
|
||||
# Decide whether this attempt succeeds. Unseeded randomness so
|
||||
# scanners can't predict which creds will "work".
|
||||
import random as _rand
|
||||
if _LOGIN_MODE == "never":
|
||||
accept = False
|
||||
elif _LOGIN_MODE == "always":
|
||||
accept = True
|
||||
else:
|
||||
accept = _rand.random() < _LOGIN_SUCCESS_RATE
|
||||
if not accept:
|
||||
return defer.succeed((530, "Login incorrect."))
|
||||
self.state = self.AUTHED
|
||||
self._user = getattr(self, "_server_user", "anonymous")
|
||||
self.shell = FTPAnonymousShell(FilePath(_BAIT_PATH))
|
||||
return defer.succeed((230, "Login successful."))
|
||||
|
||||
def ftp_RETR(self, path):
|
||||
_log("download_attempt", path=path)
|
||||
return super().ftp_RETR(path)
|
||||
|
||||
def connectionLost(self, reason):
|
||||
peer = self.transport.getPeer()
|
||||
_log("disconnect", src_ip=peer.host, src_port=peer.port)
|
||||
super().connectionLost(reason)
|
||||
|
||||
|
||||
class ServerFTPFactory(FTPFactory):
|
||||
protocol = ServerFTP
|
||||
welcomeMessage = BANNER
|
||||
|
||||
if __name__ == "__main__":
|
||||
twisted_log.startLoggingWithObserver(lambda e: None, setStdout=False)
|
||||
_log("startup", msg=f"FTP server starting as {NODE_NAME} on port {PORT}")
|
||||
reactor.listenTCP(PORT, ServerFTPFactory())
|
||||
reactor.run()
|
||||
261
decnet/templates/ftp/syslog_bridge.py
Normal file
261
decnet/templates/ftp/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
|
||||
27
decnet/templates/http/Dockerfile
Normal file
27
decnet/templates/http/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN pip3 install --no-cache-dir flask jinja2
|
||||
|
||||
COPY syslog_bridge.py /opt/syslog_bridge.py
|
||||
COPY instance_seed.py /opt/instance_seed.py
|
||||
COPY server.py /opt/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 80 443
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER logrelay
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
3
decnet/templates/http/entrypoint.sh
Normal file
3
decnet/templates/http/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
exec python3 /opt/server.py
|
||||
120
decnet/templates/http/instance_seed.py
Normal file
120
decnet/templates/http/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)
|
||||
165
decnet/templates/http/server.py
Normal file
165
decnet/templates/http/server.py
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
HTTP service emulator using Flask.
|
||||
Accepts all requests, logs every detail (method, path, headers, body),
|
||||
and responds with configurable pages. Forwards events as JSON to LOG_TARGET if set.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, request, send_from_directory
|
||||
from werkzeug.serving import make_server, WSGIRequestHandler
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import (
|
||||
classify_authorization,
|
||||
extract_form_credentials,
|
||||
forward_syslog,
|
||||
syslog_line,
|
||||
write_syslog_file,
|
||||
)
|
||||
|
||||
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "webserver")
|
||||
SERVICE_NAME = "http"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
PORT = int(os.environ.get("PORT", "80"))
|
||||
|
||||
# Per-instance Server header. Every decky running one identical Apache
|
||||
# version string is a one-query fleet discovery for any scanner.
|
||||
# Distribution shaped toward currently-deployed-in-the-wild versions.
|
||||
_SERVER_CHOICES = [
|
||||
"Apache/2.4.41 (Ubuntu)",
|
||||
"Apache/2.4.52 (Ubuntu)",
|
||||
"Apache/2.4.54 (Debian)",
|
||||
"Apache/2.4.56 (Debian)",
|
||||
"Apache/2.4.57 (Debian)",
|
||||
"Apache/2.4.58 (Ubuntu)",
|
||||
"Apache/2.4.59 (Debian)",
|
||||
"nginx/1.18.0 (Ubuntu)",
|
||||
"nginx/1.22.1",
|
||||
"nginx/1.24.0 (Ubuntu)",
|
||||
"nginx/1.25.3",
|
||||
]
|
||||
SERVER_HEADER = os.environ.get("SERVER_HEADER") or _seed.pick(_SERVER_CHOICES)
|
||||
RESPONSE_CODE = int(os.environ.get("RESPONSE_CODE", "403"))
|
||||
FAKE_APP = os.environ.get("FAKE_APP", "")
|
||||
EXTRA_HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}"))
|
||||
CUSTOM_BODY = os.environ.get("CUSTOM_BODY", "")
|
||||
FILES_DIR = os.environ.get("FILES_DIR", "")
|
||||
|
||||
_FAKE_APP_BODIES: dict[str, str] = {
|
||||
"apache_default": (
|
||||
"<!DOCTYPE HTML PUBLIC \"-//IETF//DTD HTML 2.0//EN\">\n"
|
||||
"<html><head><title>Apache2 Debian Default Page</title></head>\n"
|
||||
"<body><h1>Apache2 Debian Default Page</h1>\n"
|
||||
"<p>It works!</p></body></html>"
|
||||
),
|
||||
"nginx_default": (
|
||||
"<!DOCTYPE html><html><head><title>Welcome to nginx!</title></head>\n"
|
||||
"<body><h1>Welcome to nginx!</h1>\n"
|
||||
"<p>If you see this page, the nginx web server is successfully installed.</p>\n"
|
||||
"</body></html>"
|
||||
),
|
||||
"wordpress": (
|
||||
"<!DOCTYPE html><html><head><title>WordPress › Error</title></head>\n"
|
||||
"<body id=\"error-page\"><div class=\"wp-die-message\">\n"
|
||||
"<h1>Error establishing a database connection</h1></div></body></html>"
|
||||
),
|
||||
"phpmyadmin": (
|
||||
"<!DOCTYPE html><html><head><title>phpMyAdmin</title></head>\n"
|
||||
"<body><form method=\"post\" action=\"index.php\">\n"
|
||||
"<input type=\"text\" name=\"pma_username\" />\n"
|
||||
"<input type=\"password\" name=\"pma_password\" />\n"
|
||||
"<input type=\"submit\" value=\"Go\" /></form></body></html>"
|
||||
),
|
||||
"iis_default": (
|
||||
"<!DOCTYPE html><html><head><title>IIS Windows Server</title></head>\n"
|
||||
"<body><h1>IIS Windows Server</h1>\n"
|
||||
"<p>Welcome to Internet Information Services</p></body></html>"
|
||||
),
|
||||
}
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.after_request
|
||||
def _fix_server_header(response):
|
||||
response.headers["Server"] = SERVER_HEADER
|
||||
return response
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
@app.before_request
|
||||
def log_request():
|
||||
# Cred extraction precedence:
|
||||
# 1. Authorization header (Basic / Bearer / Digest)
|
||||
# 2. POST form body (application/x-www-form-urlencoded with
|
||||
# common login field names: username/user/email/login/...)
|
||||
# Header wins when present — the form body might be a follow-up
|
||||
# password change or a reset, while the Authorization is the
|
||||
# current session credential.
|
||||
body = request.get_data(as_text=True)[:4096]
|
||||
cred = (
|
||||
classify_authorization(request.headers.get("Authorization"))
|
||||
or extract_form_credentials(body, request.headers.get("Content-Type"))
|
||||
)
|
||||
_log(
|
||||
"request",
|
||||
method=request.method,
|
||||
path=request.path,
|
||||
remote_addr=request.remote_addr,
|
||||
headers=json.dumps(dict(request.headers)),
|
||||
body=body[:512],
|
||||
**(cred or {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/", defaults={"path": ""})
|
||||
@app.route("/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||
def catch_all(path):
|
||||
# Serve static files directory if configured
|
||||
if FILES_DIR and path:
|
||||
files_path = Path(FILES_DIR) / path
|
||||
if files_path.is_file():
|
||||
return send_from_directory(FILES_DIR, path)
|
||||
|
||||
# Select response body: custom > fake_app preset > default 403
|
||||
if CUSTOM_BODY:
|
||||
body = CUSTOM_BODY
|
||||
elif FAKE_APP and FAKE_APP in _FAKE_APP_BODIES:
|
||||
body = _FAKE_APP_BODIES[FAKE_APP]
|
||||
else:
|
||||
body = (
|
||||
"<!DOCTYPE HTML PUBLIC \"-//IETF//DTD HTML 2.0//EN\">\n"
|
||||
"<html><head>\n"
|
||||
"<title>403 Forbidden</title>\n"
|
||||
"</head><body>\n"
|
||||
"<h1>Forbidden</h1>\n"
|
||||
"<p>You don't have permission to access this resource.</p>\n"
|
||||
"<hr>\n"
|
||||
f"<address>{SERVER_HEADER} Server at {NODE_NAME} Port 80</address>\n"
|
||||
"</body></html>\n"
|
||||
)
|
||||
|
||||
headers = {"Content-Type": "text/html", **EXTRA_HEADERS}
|
||||
return body, RESPONSE_CODE, headers
|
||||
|
||||
|
||||
class _SilentHandler(WSGIRequestHandler):
|
||||
"""Suppress Werkzeug's Server header so Flask's after_request is the sole source."""
|
||||
def version_string(self) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_log("startup", msg=f"HTTP server starting as {NODE_NAME}")
|
||||
srv = make_server("0.0.0.0", PORT, app, request_handler=_SilentHandler) # nosec B104
|
||||
srv.serve_forever()
|
||||
261
decnet/templates/http/syslog_bridge.py
Normal file
261
decnet/templates/http/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
|
||||
30
decnet/templates/https/Dockerfile
Normal file
30
decnet/templates/https/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN pip3 install --no-cache-dir flask jinja2
|
||||
|
||||
COPY syslog_bridge.py /opt/syslog_bridge.py
|
||||
COPY instance_seed.py /opt/instance_seed.py
|
||||
COPY server.py /opt/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
RUN mkdir -p /opt/tls
|
||||
|
||||
EXPOSE 443
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& chown -R logrelay:logrelay /opt/tls \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER logrelay
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
18
decnet/templates/https/entrypoint.sh
Normal file
18
decnet/templates/https/entrypoint.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
TLS_DIR="/opt/tls"
|
||||
CERT="${TLS_CERT:-$TLS_DIR/cert.pem}"
|
||||
KEY="${TLS_KEY:-$TLS_DIR/key.pem}"
|
||||
|
||||
# Generate a self-signed certificate if none exists
|
||||
if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then
|
||||
mkdir -p "$TLS_DIR"
|
||||
CN="${TLS_CN:-${NODE_NAME:-localhost}}"
|
||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout "$KEY" -out "$CERT" \
|
||||
-days 3650 -subj "/CN=$CN" \
|
||||
2>/dev/null
|
||||
fi
|
||||
|
||||
exec python3 /opt/server.py
|
||||
120
decnet/templates/https/instance_seed.py
Normal file
120
decnet/templates/https/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)
|
||||
164
decnet/templates/https/server.py
Normal file
164
decnet/templates/https/server.py
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
HTTPS service emulator using Flask + TLS.
|
||||
Identical to the HTTP honeypot but wrapped in TLS. Accepts all requests,
|
||||
logs every detail (method, path, headers, body, TLS info), and responds
|
||||
with configurable pages. Forwards events as JSON to LOG_TARGET if set.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, request, send_from_directory
|
||||
from werkzeug.serving import make_server, WSGIRequestHandler
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import (
|
||||
classify_authorization,
|
||||
extract_form_credentials,
|
||||
forward_syslog,
|
||||
syslog_line,
|
||||
write_syslog_file,
|
||||
)
|
||||
|
||||
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "webserver")
|
||||
SERVICE_NAME = "https"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
PORT = int(os.environ.get("PORT", "443"))
|
||||
|
||||
_SERVER_CHOICES = [
|
||||
"Apache/2.4.41 (Ubuntu)",
|
||||
"Apache/2.4.52 (Ubuntu)",
|
||||
"Apache/2.4.54 (Debian)",
|
||||
"Apache/2.4.56 (Debian)",
|
||||
"Apache/2.4.57 (Debian)",
|
||||
"Apache/2.4.58 (Ubuntu)",
|
||||
"Apache/2.4.59 (Debian)",
|
||||
"nginx/1.18.0 (Ubuntu)",
|
||||
"nginx/1.22.1",
|
||||
"nginx/1.24.0 (Ubuntu)",
|
||||
"nginx/1.25.3",
|
||||
]
|
||||
SERVER_HEADER = os.environ.get("SERVER_HEADER") or _seed.pick(_SERVER_CHOICES)
|
||||
RESPONSE_CODE = int(os.environ.get("RESPONSE_CODE", "403"))
|
||||
FAKE_APP = os.environ.get("FAKE_APP", "")
|
||||
EXTRA_HEADERS = json.loads(os.environ.get("EXTRA_HEADERS", "{}"))
|
||||
CUSTOM_BODY = os.environ.get("CUSTOM_BODY", "")
|
||||
FILES_DIR = os.environ.get("FILES_DIR", "")
|
||||
TLS_CERT = os.environ.get("TLS_CERT", "/opt/tls/cert.pem")
|
||||
TLS_KEY = os.environ.get("TLS_KEY", "/opt/tls/key.pem")
|
||||
|
||||
_FAKE_APP_BODIES: dict[str, str] = {
|
||||
"apache_default": (
|
||||
"<!DOCTYPE HTML PUBLIC \"-//IETF//DTD HTML 2.0//EN\">\n"
|
||||
"<html><head><title>Apache2 Debian Default Page</title></head>\n"
|
||||
"<body><h1>Apache2 Debian Default Page</h1>\n"
|
||||
"<p>It works!</p></body></html>"
|
||||
),
|
||||
"nginx_default": (
|
||||
"<!DOCTYPE html><html><head><title>Welcome to nginx!</title></head>\n"
|
||||
"<body><h1>Welcome to nginx!</h1>\n"
|
||||
"<p>If you see this page, the nginx web server is successfully installed.</p>\n"
|
||||
"</body></html>"
|
||||
),
|
||||
"wordpress": (
|
||||
"<!DOCTYPE html><html><head><title>WordPress › Error</title></head>\n"
|
||||
"<body id=\"error-page\"><div class=\"wp-die-message\">\n"
|
||||
"<h1>Error establishing a database connection</h1></div></body></html>"
|
||||
),
|
||||
"phpmyadmin": (
|
||||
"<!DOCTYPE html><html><head><title>phpMyAdmin</title></head>\n"
|
||||
"<body><form method=\"post\" action=\"index.php\">\n"
|
||||
"<input type=\"text\" name=\"pma_username\" />\n"
|
||||
"<input type=\"password\" name=\"pma_password\" />\n"
|
||||
"<input type=\"submit\" value=\"Go\" /></form></body></html>"
|
||||
),
|
||||
"iis_default": (
|
||||
"<!DOCTYPE html><html><head><title>IIS Windows Server</title></head>\n"
|
||||
"<body><h1>IIS Windows Server</h1>\n"
|
||||
"<p>Welcome to Internet Information Services</p></body></html>"
|
||||
),
|
||||
}
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.after_request
|
||||
def _fix_server_header(response):
|
||||
response.headers["Server"] = SERVER_HEADER
|
||||
return response
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
@app.before_request
|
||||
def log_request():
|
||||
body = request.get_data(as_text=True)[:4096]
|
||||
cred = (
|
||||
classify_authorization(request.headers.get("Authorization"))
|
||||
or extract_form_credentials(body, request.headers.get("Content-Type"))
|
||||
)
|
||||
_log(
|
||||
"request",
|
||||
method=request.method,
|
||||
path=request.path,
|
||||
remote_addr=request.remote_addr,
|
||||
headers=dict(request.headers),
|
||||
body=body[:512],
|
||||
**(cred or {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/", defaults={"path": ""})
|
||||
@app.route("/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||
def catch_all(path):
|
||||
# Serve static files directory if configured
|
||||
if FILES_DIR and path:
|
||||
files_path = Path(FILES_DIR) / path
|
||||
if files_path.is_file():
|
||||
return send_from_directory(FILES_DIR, path)
|
||||
|
||||
# Select response body: custom > fake_app preset > default 403
|
||||
if CUSTOM_BODY:
|
||||
body = CUSTOM_BODY
|
||||
elif FAKE_APP and FAKE_APP in _FAKE_APP_BODIES:
|
||||
body = _FAKE_APP_BODIES[FAKE_APP]
|
||||
else:
|
||||
body = (
|
||||
"<!DOCTYPE HTML PUBLIC \"-//IETF//DTD HTML 2.0//EN\">\n"
|
||||
"<html><head>\n"
|
||||
"<title>403 Forbidden</title>\n"
|
||||
"</head><body>\n"
|
||||
"<h1>Forbidden</h1>\n"
|
||||
"<p>You don't have permission to access this resource.</p>\n"
|
||||
"<hr>\n"
|
||||
f"<address>{SERVER_HEADER} Server at {NODE_NAME} Port 443</address>\n"
|
||||
"</body></html>\n"
|
||||
)
|
||||
|
||||
headers = {"Content-Type": "text/html", **EXTRA_HEADERS}
|
||||
return body, RESPONSE_CODE, headers
|
||||
|
||||
|
||||
class _SilentHandler(WSGIRequestHandler):
|
||||
"""Suppress Werkzeug's Server header so Flask's after_request is the sole source."""
|
||||
def version_string(self) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_log("startup", msg=f"HTTPS server starting as {NODE_NAME}")
|
||||
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.load_cert_chain(TLS_CERT, TLS_KEY)
|
||||
|
||||
srv = make_server("0.0.0.0", PORT, app, request_handler=_SilentHandler) # nosec B104
|
||||
srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
|
||||
srv.serve_forever()
|
||||
261
decnet/templates/https/syslog_bridge.py
Normal file
261
decnet/templates/https/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
|
||||
23
decnet/templates/imap/Dockerfile
Normal file
23
decnet/templates/imap/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY syslog_bridge.py /opt/syslog_bridge.py
|
||||
COPY server.py /opt/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 143 993
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER logrelay
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
3
decnet/templates/imap/entrypoint.sh
Normal file
3
decnet/templates/imap/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
exec python3 /opt/server.py
|
||||
120
decnet/templates/imap/instance_seed.py
Normal file
120
decnet/templates/imap/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)
|
||||
679
decnet/templates/imap/server.py
Normal file
679
decnet/templates/imap/server.py
Normal file
@@ -0,0 +1,679 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
IMAP server (port 143).
|
||||
Full IMAP4rev1 state machine with bait mailbox.
|
||||
|
||||
States: NOT_AUTHENTICATED → AUTHENTICATED → SELECTED
|
||||
|
||||
Credentials via IMAP_USERS env var ("user:pass,user2:pass2").
|
||||
10 bait emails in INBOX containing AWS keys, DB passwords, tokens etc.
|
||||
Banner advertises Dovecot so nmap fingerprints correctly.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import email
|
||||
import email.policy
|
||||
import os
|
||||
import time
|
||||
from email.utils import getaddresses
|
||||
from pathlib import Path
|
||||
from syslog_bridge import (
|
||||
SEVERITY_WARNING,
|
||||
encode_secret,
|
||||
forward_syslog,
|
||||
syslog_line,
|
||||
write_syslog_file,
|
||||
)
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "mailserver")
|
||||
SERVICE_NAME = "imap"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
PORT = int(os.environ.get("PORT", "143"))
|
||||
IMAP_BANNER = os.environ.get("IMAP_BANNER", "* OK Dovecot ready.\r\n")
|
||||
_RAW_USERS = os.environ.get("IMAP_USERS", "admin:admin123,root:toor,mail:mail,user:user")
|
||||
|
||||
VALID_USERS: dict[str, str] = {
|
||||
u: p for part in _RAW_USERS.split(",") if ":" in part for u, p in [part.split(":", 1)]
|
||||
}
|
||||
|
||||
# Path to a directory of ``*.eml`` files that the orchestrator emailgen
|
||||
# worker drops into the container (``/var/spool/decnet-emails/`` by
|
||||
# convention). When set AND the directory contains parseable EMLs,
|
||||
# they replace the hardcoded ``_BAIT_EMAILS`` fallback below — meaning
|
||||
# every mail an attacker reads is the LLM-generated, persona-driven,
|
||||
# language-aware version, not the static credential-stuffed bait list.
|
||||
# Empty / missing / unparseable: the fallback list still serves so a
|
||||
# fresh deployment is never silent.
|
||||
_EMAIL_SEED_PATH = os.environ.get("IMAP_EMAIL_SEED", "")
|
||||
# Re-scan the seed directory at most this often. Cheap: walking a few
|
||||
# dozen .eml files is sub-millisecond, but caching keeps an attacker's
|
||||
# rapid LIST/FETCH burst from re-parsing the same files on every
|
||||
# command. Mtime check still triggers a re-load on real changes.
|
||||
_SEED_RESCAN_INTERVAL = float(os.environ.get("IMAP_EMAIL_SEED_RESCAN", "5"))
|
||||
|
||||
# ── Bait emails ───────────────────────────────────────────────────────────────
|
||||
# All 10 live in INBOX. UID == sequence number.
|
||||
|
||||
_BAIT_EMAILS: list[dict] = [
|
||||
{
|
||||
"uid": 1, "flags": [r"\Seen"],
|
||||
"from_name": "DevOps Team", "from_addr": "devops@company.internal",
|
||||
"to_addr": "admin@company.internal",
|
||||
"subject": "AWS credentials rotation",
|
||||
"date": "Mon, 06 Nov 2023 09:12:33 +0000",
|
||||
"body": (
|
||||
"Date: Mon, 06 Nov 2023 09:12:33 +0000\r\n"
|
||||
"From: DevOps Team <devops@company.internal>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: AWS credentials rotation\r\n"
|
||||
"Message-ID: <1@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"Team,\r\n\r\n"
|
||||
"New AWS credentials have been issued. Old keys deactivated.\r\n\r\n"
|
||||
"Access Key ID: AKIAIOSFODNN7EXAMPLE\r\n"
|
||||
"Secret Access Key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\r\n\r\n"
|
||||
"Update ~/.aws/credentials immediately.\r\n\r\n-- DevOps\r\n"
|
||||
),
|
||||
},
|
||||
{
|
||||
"uid": 2, "flags": [r"\Seen"],
|
||||
"from_name": "Monitoring", "from_addr": "monitoring@company.internal",
|
||||
"to_addr": "admin@company.internal",
|
||||
"subject": "DB password changed",
|
||||
"date": "Tue, 07 Nov 2023 14:05:11 +0000",
|
||||
"body": (
|
||||
"Date: Tue, 07 Nov 2023 14:05:11 +0000\r\n"
|
||||
"From: Monitoring <monitoring@company.internal>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: DB password changed\r\n"
|
||||
"Message-ID: <2@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"Production database password was rotated.\r\n\r\n"
|
||||
"Connection string: mysql://admin:Sup3rS3cr3t!@10.0.1.5:3306/production\r\n\r\n"
|
||||
"Update all app configs.\r\n"
|
||||
),
|
||||
},
|
||||
{
|
||||
"uid": 3, "flags": [],
|
||||
"from_name": "GitHub", "from_addr": "noreply@github.com",
|
||||
"to_addr": "admin@company.internal",
|
||||
"subject": "Your personal access token",
|
||||
"date": "Wed, 08 Nov 2023 08:30:00 +0000",
|
||||
"body": (
|
||||
"Date: Wed, 08 Nov 2023 08:30:00 +0000\r\n"
|
||||
"From: GitHub <noreply@github.com>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: Your personal access token\r\n"
|
||||
"Message-ID: <3@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"Hi admin,\r\n\r\n"
|
||||
"A new personal access token was created for your account.\r\n\r\n"
|
||||
"Token: ghp_16C7e42F292c6912E7710c838347Ae178B4a\r\n\r\n"
|
||||
"If this wasn't you, revoke it immediately at github.com/settings/tokens.\r\n"
|
||||
),
|
||||
},
|
||||
{
|
||||
"uid": 4, "flags": [r"\Seen"],
|
||||
"from_name": "IT Admin", "from_addr": "admin@company.internal",
|
||||
"to_addr": "team@company.internal",
|
||||
"subject": "VPN config attached",
|
||||
"date": "Thu, 09 Nov 2023 11:22:47 +0000",
|
||||
"body": (
|
||||
"Date: Thu, 09 Nov 2023 11:22:47 +0000\r\n"
|
||||
"From: IT Admin <admin@company.internal>\r\n"
|
||||
"To: team@company.internal\r\n"
|
||||
"Subject: VPN config attached\r\n"
|
||||
"Message-ID: <4@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"VPN access details for new starters:\r\n\r\n"
|
||||
" Host: vpn.company.internal:1194\r\n"
|
||||
" Protocol: UDP\r\n"
|
||||
" Username: vpnadmin\r\n"
|
||||
" Password: VpnP@ss2024\r\n\r\n"
|
||||
"Config file sent separately via secure channel.\r\n"
|
||||
),
|
||||
},
|
||||
{
|
||||
"uid": 5, "flags": [],
|
||||
"from_name": "SysAdmin", "from_addr": "sysadmin@company.internal",
|
||||
"to_addr": "admin@company.internal",
|
||||
"subject": "Root password",
|
||||
"date": "Fri, 10 Nov 2023 16:45:00 +0000",
|
||||
"body": (
|
||||
"Date: Fri, 10 Nov 2023 16:45:00 +0000\r\n"
|
||||
"From: SysAdmin <sysadmin@company.internal>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: Root password\r\n"
|
||||
"Message-ID: <5@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"New root password for prod servers:\r\n\r\n"
|
||||
" r00tM3T00!\r\n\r\n"
|
||||
"Change after first login. Do NOT forward this email.\r\n"
|
||||
),
|
||||
},
|
||||
{
|
||||
"uid": 6, "flags": [r"\Seen"],
|
||||
"from_name": "Backup System", "from_addr": "backup@company.internal",
|
||||
"to_addr": "admin@company.internal",
|
||||
"subject": "Backup job failed",
|
||||
"date": "Sat, 11 Nov 2023 03:12:04 +0000",
|
||||
"body": (
|
||||
"Date: Sat, 11 Nov 2023 03:12:04 +0000\r\n"
|
||||
"From: Backup System <backup@company.internal>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: Backup job failed\r\n"
|
||||
"Message-ID: <6@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"Nightly backup to 192.168.1.50:/mnt/nas FAILED at 03:11 UTC.\r\n\r\n"
|
||||
"Error: Authentication failed. Credentials in /etc/backup.conf may be stale.\r\n\r\n"
|
||||
"Last successful backup: 2023-11-10 03:11 UTC\r\n"
|
||||
),
|
||||
},
|
||||
{
|
||||
"uid": 7, "flags": [r"\Seen"],
|
||||
"from_name": "Security Alerts", "from_addr": "alerts@company.internal",
|
||||
"to_addr": "admin@company.internal",
|
||||
"subject": "SSH brute-force alert",
|
||||
"date": "Sun, 12 Nov 2023 07:04:31 +0000",
|
||||
"body": (
|
||||
"Date: Sun, 12 Nov 2023 07:04:31 +0000\r\n"
|
||||
"From: Security Alerts <alerts@company.internal>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: SSH brute-force alert\r\n"
|
||||
"Message-ID: <7@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"47 failed SSH login attempts detected against prod-web-01.\r\n\r\n"
|
||||
"Source IPs: 185.220.101.34, 185.220.101.47, 185.220.101.52\r\n"
|
||||
"Target user: root\r\n"
|
||||
"Period: 2023-11-12 06:58 – 07:04 UTC\r\n\r\n"
|
||||
"All attempts blocked by fail2ban. No successful logins.\r\n"
|
||||
),
|
||||
},
|
||||
{
|
||||
"uid": 8, "flags": [r"\Seen"],
|
||||
"from_name": "External Vendor", "from_addr": "vendor@external.com",
|
||||
"to_addr": "admin@company.internal",
|
||||
"subject": "RE: API integration",
|
||||
"date": "Mon, 13 Nov 2023 10:11:55 +0000",
|
||||
"body": (
|
||||
"Date: Mon, 13 Nov 2023 10:11:55 +0000\r\n"
|
||||
"From: External Vendor <vendor@external.com>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: RE: API integration\r\n"
|
||||
"Message-ID: <8@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"Hi,\r\n\r\n"
|
||||
"Here is the live API key for the integration:\r\n\r\n"
|
||||
" sk_live_9mK3xF2aP7qR1bN8cT4dW6vE0yU5hJ\r\n\r\n"
|
||||
"Keep this confidential. Let me know if you need the webhook secret.\r\n\r\n"
|
||||
"Best regards,\r\nVendor Support\r\n"
|
||||
),
|
||||
},
|
||||
{
|
||||
"uid": 9, "flags": [],
|
||||
"from_name": "Help Desk", "from_addr": "helpdesk@company.internal",
|
||||
"to_addr": "admin@company.internal",
|
||||
"subject": "Password reset request",
|
||||
"date": "Tue, 14 Nov 2023 13:48:22 +0000",
|
||||
"body": (
|
||||
"Date: Tue, 14 Nov 2023 13:48:22 +0000\r\n"
|
||||
"From: Help Desk <helpdesk@company.internal>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: Password reset request\r\n"
|
||||
"Message-ID: <9@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"Hi,\r\n\r\n"
|
||||
"Could you reset my MFA? Current password is Winter2024! so you can verify it's me.\r\n\r\n"
|
||||
"Thanks\r\n"
|
||||
),
|
||||
},
|
||||
{
|
||||
"uid": 10, "flags": [r"\Seen"],
|
||||
"from_name": "AWS Billing", "from_addr": "noreply@aws.amazon.com",
|
||||
"to_addr": "admin@company.internal",
|
||||
"subject": "Your AWS bill is ready",
|
||||
"date": "Wed, 15 Nov 2023 00:01:00 +0000",
|
||||
"body": (
|
||||
"Date: Wed, 15 Nov 2023 00:01:00 +0000\r\n"
|
||||
"From: AWS Billing <noreply@aws.amazon.com>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: Your AWS bill is ready\r\n"
|
||||
"Message-ID: <10@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"Your AWS bill for October 2023 is $847.23.\r\n\r\n"
|
||||
"Top services:\r\n"
|
||||
" EC2 (us-east-1): $412.10\r\n"
|
||||
" RDS (us-east-1): $198.50\r\n"
|
||||
" S3: $87.43\r\n"
|
||||
" EC2 (eu-west-2): $149.20\r\n\r\n"
|
||||
"Account ID: 123456789012\r\n"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
_MAILBOXES = ["INBOX", "Sent", "Drafts", "Archive"]
|
||||
|
||||
|
||||
# ── Spool-backed email loader ─────────────────────────────────────────────────
|
||||
# When IMAP_EMAIL_SEED points at a directory of .eml files the
|
||||
# orchestrator emailgen worker has dropped into the container, parse
|
||||
# them on demand and serve them as the INBOX. Cached between requests
|
||||
# with a short TTL + mtime check so a hot mailbox doesn't pay the parse
|
||||
# cost on every IMAP command.
|
||||
#
|
||||
# Failure modes (missing dir, unparseable EMLs, empty dir) all return
|
||||
# the hardcoded fallback rather than 0 messages — a silent INBOX is a
|
||||
# stronger tell than a slightly-stale one.
|
||||
|
||||
_seed_cache: list[dict] | None = None
|
||||
_seed_cache_dir_mtime: float = 0.0
|
||||
_seed_cache_loaded_at: float = 0.0
|
||||
|
||||
|
||||
def _split_addr(value: str) -> tuple[str, str]:
|
||||
"""Return (display_name, email) from a header value, falling back to
|
||||
the raw string when the parse fails. Worker side; we don't need
|
||||
real RFC 5322 — just enough to populate the IMAP envelope tuple."""
|
||||
if not value:
|
||||
return "", ""
|
||||
pairs = getaddresses([value])
|
||||
if not pairs:
|
||||
return "", value
|
||||
name, addr = pairs[0]
|
||||
return (name or "").strip(), (addr or value).strip()
|
||||
|
||||
|
||||
def _eml_to_dict(path: Path, uid: int) -> dict | None:
|
||||
"""Parse one .eml into the dict shape the rest of this server uses.
|
||||
|
||||
Returns None when the file isn't parseable; callers skip + continue
|
||||
so one corrupt EML does not kill the whole INBOX listing.
|
||||
"""
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
msg = email.message_from_bytes(raw, policy=email.policy.compat32)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
from_name, from_addr = _split_addr(msg.get("From", ""))
|
||||
_to_name, to_addr = _split_addr(msg.get("To", ""))
|
||||
subject = (msg.get("Subject") or "").strip()
|
||||
date = msg.get("Date") or ""
|
||||
return {
|
||||
"uid": uid,
|
||||
"flags": [], # never \Seen for spool emails — fresh delivery
|
||||
"from_name": from_name or from_addr.split("@", 1)[0] if from_addr else "Unknown",
|
||||
"from_addr": from_addr or "unknown@localhost",
|
||||
"to_addr": to_addr or "unknown@localhost",
|
||||
"subject": subject or "(no subject)",
|
||||
"date": date,
|
||||
# The body field carries the full RFC 822 message — headers + body.
|
||||
# That mirrors how the hardcoded _BAIT_EMAILS entries are shaped.
|
||||
"body": raw.decode("utf-8", errors="replace"),
|
||||
}
|
||||
|
||||
|
||||
def _scan_seed_dir(path: Path) -> list[dict]:
|
||||
"""Walk *path* recursively, parse every ``*.eml``, sort by mtime."""
|
||||
eml_paths: list[Path] = []
|
||||
try:
|
||||
for p in path.rglob("*.eml"):
|
||||
if p.is_file():
|
||||
eml_paths.append(p)
|
||||
except OSError:
|
||||
return []
|
||||
eml_paths.sort(key=lambda p: p.stat().st_mtime)
|
||||
out: list[dict] = []
|
||||
for i, p in enumerate(eml_paths, start=1):
|
||||
d = _eml_to_dict(p, uid=i)
|
||||
if d is not None:
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _get_emails() -> list[dict]:
|
||||
"""Return the active mailbox list.
|
||||
|
||||
Resolution order:
|
||||
1. ``IMAP_EMAIL_SEED`` set + dir exists + at least one parseable EML
|
||||
→ that list (rescan-throttled).
|
||||
2. Else → the hardcoded ``_BAIT_EMAILS`` fallback.
|
||||
"""
|
||||
global _seed_cache, _seed_cache_dir_mtime, _seed_cache_loaded_at
|
||||
if not _EMAIL_SEED_PATH:
|
||||
return _BAIT_EMAILS
|
||||
seed_dir = Path(_EMAIL_SEED_PATH)
|
||||
try:
|
||||
dir_stat = seed_dir.stat()
|
||||
except OSError:
|
||||
return _BAIT_EMAILS
|
||||
now = time.monotonic()
|
||||
fresh_enough = (
|
||||
_seed_cache is not None
|
||||
and (now - _seed_cache_loaded_at) < _SEED_RESCAN_INTERVAL
|
||||
and dir_stat.st_mtime == _seed_cache_dir_mtime
|
||||
)
|
||||
if fresh_enough:
|
||||
return _seed_cache or _BAIT_EMAILS
|
||||
scanned = _scan_seed_dir(seed_dir)
|
||||
if not scanned:
|
||||
# Don't poison the cache with an empty list; a single early
|
||||
# FETCH before emailgen has run would otherwise stick the
|
||||
# mailbox at 0 for _SEED_RESCAN_INTERVAL seconds.
|
||||
return _BAIT_EMAILS
|
||||
_seed_cache = scanned
|
||||
_seed_cache_dir_mtime = dir_stat.st_mtime
|
||||
_seed_cache_loaded_at = now
|
||||
return scanned
|
||||
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
# ── FETCH helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_seq_range(range_str: str, total: int) -> list[int]:
|
||||
"""Parse IMAP sequence set ('1', '1:3', '1:*', '*') → list of 1-based indices."""
|
||||
result = []
|
||||
for part in range_str.split(","):
|
||||
part = part.strip()
|
||||
if ":" in part:
|
||||
lo_s, hi_s = part.split(":", 1)
|
||||
lo = total if lo_s == "*" else int(lo_s)
|
||||
hi = total if hi_s == "*" else int(hi_s)
|
||||
result.extend(range(min(lo, hi), max(lo, hi) + 1))
|
||||
elif part == "*":
|
||||
result.append(total)
|
||||
else:
|
||||
result.append(int(part))
|
||||
return [n for n in result if 1 <= n <= total]
|
||||
|
||||
|
||||
def _parse_fetch_items(items_str: str) -> list[str]:
|
||||
"""Parse '(FLAGS ENVELOPE)' or 'BODY[]' → list of item name strings."""
|
||||
s = items_str.strip()
|
||||
if s.startswith("(") and s.endswith(")"):
|
||||
s = s[1:-1]
|
||||
tokens, i = [], 0
|
||||
while i < len(s):
|
||||
if s[i] == " ":
|
||||
i += 1
|
||||
continue
|
||||
j, depth = i, 0
|
||||
while j < len(s):
|
||||
if s[j] == "[":
|
||||
depth += 1
|
||||
elif s[j] == "]":
|
||||
depth -= 1
|
||||
elif s[j] == " " and depth == 0:
|
||||
break
|
||||
j += 1
|
||||
tokens.append(s[i:j].upper())
|
||||
i = j
|
||||
return tokens
|
||||
|
||||
|
||||
def _envelope(msg: dict) -> str:
|
||||
"""Build minimal RFC 3501 ENVELOPE tuple string."""
|
||||
def addr(name: str, email: str) -> str:
|
||||
parts = email.split("@", 1)
|
||||
user = parts[0]
|
||||
host = parts[1] if len(parts) > 1 else ""
|
||||
safe_name = name.replace('"', '\\"')
|
||||
return f'("{safe_name}" NIL "{user}" "{host}")'
|
||||
|
||||
from_addr = addr(msg["from_name"], msg["from_addr"])
|
||||
to_addr = addr("", msg["to_addr"])
|
||||
subject = msg["subject"].replace('"', '\\"')
|
||||
return (
|
||||
f'("{msg["date"]}" "{subject}" '
|
||||
f'({from_addr}) ({from_addr}) ({from_addr}) '
|
||||
f'({to_addr}) NIL NIL NIL "<{msg["uid"]}@{NODE_NAME}>")'
|
||||
)
|
||||
|
||||
|
||||
def _build_fetch_response(seq: int, msg: dict, items: list[str]) -> bytes:
|
||||
"""Build the bytes for a single '* N FETCH (...)' response."""
|
||||
non_literal: list[str] = []
|
||||
literal_name: str | None = None
|
||||
literal_raw: bytes | None = None
|
||||
|
||||
for item in items:
|
||||
norm = item.upper()
|
||||
if norm == "FLAGS":
|
||||
flags = " ".join(msg["flags"]) if msg["flags"] else ""
|
||||
non_literal.append(f"FLAGS ({flags})")
|
||||
elif norm == "ENVELOPE":
|
||||
non_literal.append(f"ENVELOPE {_envelope(msg)}")
|
||||
elif norm == "RFC822.SIZE":
|
||||
non_literal.append(f"RFC822.SIZE {len(msg['body'].encode())}")
|
||||
elif norm in ("UID",):
|
||||
non_literal.append(f"UID {msg['uid']}")
|
||||
elif norm in ("BODY[]", "RFC822", "BODY[TEXT]", "BODY.PEEK[]"):
|
||||
literal_name = "BODY[]"
|
||||
literal_raw = msg["body"].encode()
|
||||
elif norm in ("BODY[HEADER]", "BODY.PEEK[HEADER]"):
|
||||
header_part = msg["body"].split("\r\n\r\n", 1)[0] + "\r\n\r\n"
|
||||
literal_name = "BODY[HEADER]"
|
||||
literal_raw = header_part.encode()
|
||||
# unknown items silently ignored
|
||||
|
||||
if literal_raw is not None:
|
||||
prefix_str = (" ".join(non_literal) + " ") if non_literal else ""
|
||||
header = f"* {seq} FETCH ({prefix_str}{literal_name} {{{len(literal_raw)}}}\r\n".encode()
|
||||
return header + literal_raw + b")\r\n"
|
||||
else:
|
||||
return f"* {seq} FETCH ({' '.join(non_literal)})\r\n".encode()
|
||||
|
||||
|
||||
# ── Protocol ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class IMAPProtocol(asyncio.Protocol):
|
||||
def __init__(self):
|
||||
self._transport = None
|
||||
self._peer = ("?", 0)
|
||||
self._buf = b""
|
||||
self._state = "NOT_AUTHENTICATED"
|
||||
self._selected = None # mailbox name currently selected
|
||||
|
||||
def connection_made(self, transport):
|
||||
self._transport = transport
|
||||
self._peer = transport.get_extra_info("peername", ("?", 0))
|
||||
_log("connect", src=self._peer[0], src_port=self._peer[1])
|
||||
banner = IMAP_BANNER if IMAP_BANNER.endswith("\r\n") else IMAP_BANNER + "\r\n"
|
||||
transport.write(banner.encode())
|
||||
|
||||
def data_received(self, data):
|
||||
self._buf += data
|
||||
while b"\n" in self._buf:
|
||||
line, self._buf = self._buf.split(b"\n", 1)
|
||||
self._handle_line(line.decode(errors="replace").strip())
|
||||
|
||||
def connection_lost(self, exc):
|
||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||
|
||||
# ── Command dispatch ──────────────────────────────────────────────────────
|
||||
|
||||
def _handle_line(self, line: str) -> None:
|
||||
parts = line.split(None, 2)
|
||||
if not parts:
|
||||
return
|
||||
tag = parts[0]
|
||||
cmd = parts[1].upper() if len(parts) > 1 else ""
|
||||
args = parts[2] if len(parts) > 2 else ""
|
||||
|
||||
_log("command", src=self._peer[0], cmd=cmd, state=self._state)
|
||||
|
||||
# Commands valid in any state
|
||||
if cmd == "CAPABILITY":
|
||||
self._w(b"* CAPABILITY IMAP4rev1 LITERAL+ SASL-IR LOGIN-REFERRALS"
|
||||
b" ID ENABLE IDLE AUTH=PLAIN AUTH=LOGIN\r\n")
|
||||
self._w(f"{tag} OK CAPABILITY completed\r\n")
|
||||
|
||||
elif cmd == "NOOP":
|
||||
self._w(f"{tag} OK\r\n")
|
||||
|
||||
elif cmd == "LOGOUT":
|
||||
self._w(b"* BYE Logging out\r\n")
|
||||
self._w(f"{tag} OK LOGOUT completed\r\n")
|
||||
self._transport.close()
|
||||
|
||||
# NOT_AUTHENTICATED only
|
||||
elif cmd == "LOGIN":
|
||||
self._cmd_login(tag, args)
|
||||
|
||||
# AUTHENTICATED or SELECTED
|
||||
elif cmd in ("LIST", "LSUB"):
|
||||
self._cmd_list(tag, cmd)
|
||||
elif cmd == "STATUS":
|
||||
self._cmd_status(tag, args)
|
||||
elif cmd in ("SELECT", "EXAMINE"):
|
||||
self._cmd_select(tag, cmd, args)
|
||||
|
||||
# SELECTED only
|
||||
elif cmd == "FETCH":
|
||||
self._cmd_fetch(tag, args, use_uid=False)
|
||||
elif cmd == "SEARCH":
|
||||
self._cmd_search(tag)
|
||||
elif cmd == "CLOSE":
|
||||
self._cmd_close(tag)
|
||||
|
||||
# UID prefix — dispatch sub-command
|
||||
elif cmd == "UID":
|
||||
sub_parts = args.split(None, 1)
|
||||
sub_cmd = sub_parts[0].upper() if sub_parts else ""
|
||||
sub_args = sub_parts[1] if len(sub_parts) > 1 else ""
|
||||
if sub_cmd == "FETCH":
|
||||
self._cmd_fetch(tag, sub_args, use_uid=True)
|
||||
elif sub_cmd == "SEARCH":
|
||||
self._cmd_search(tag, uid_mode=True)
|
||||
else:
|
||||
self._w(f"{tag} BAD Unknown UID sub-command\r\n")
|
||||
|
||||
else:
|
||||
self._w(f"{tag} BAD Command not recognized or not supported\r\n")
|
||||
|
||||
# ── Command implementations ───────────────────────────────────────────────
|
||||
|
||||
def _cmd_login(self, tag: str, args: str) -> None:
|
||||
if self._state != "NOT_AUTHENTICATED":
|
||||
self._w(f"{tag} BAD Already authenticated\r\n")
|
||||
return
|
||||
parts = args.split(None, 1)
|
||||
username = parts[0].strip('"') if parts else ""
|
||||
password = parts[1].strip('"') if len(parts) > 1 else ""
|
||||
_enc = encode_secret(password)
|
||||
if VALID_USERS.get(username) == password:
|
||||
self._state = "AUTHENTICATED"
|
||||
_log("auth", src=self._peer[0], username=username, principal=username,
|
||||
outcome="success", **_enc)
|
||||
self._w(f"{tag} OK [CAPABILITY IMAP4rev1] Logged in\r\n")
|
||||
else:
|
||||
_log("auth", src=self._peer[0], username=username, principal=username,
|
||||
outcome="failure", severity=SEVERITY_WARNING, **_enc)
|
||||
self._w(f"{tag} NO [AUTHENTICATIONFAILED] Authentication failed.\r\n")
|
||||
|
||||
def _cmd_list(self, tag: str, cmd: str) -> None:
|
||||
if self._state == "NOT_AUTHENTICATED":
|
||||
self._w(f"{tag} BAD Not authenticated\r\n")
|
||||
return
|
||||
for box in _MAILBOXES:
|
||||
self._w(f'* {cmd} (\\HasNoChildren) "/" "{box}"\r\n')
|
||||
self._w(f"{tag} OK {cmd} completed\r\n")
|
||||
|
||||
def _cmd_status(self, tag: str, args: str) -> None:
|
||||
if self._state == "NOT_AUTHENTICATED":
|
||||
self._w(f"{tag} BAD Not authenticated\r\n")
|
||||
return
|
||||
parts = args.split(None, 1)
|
||||
mailbox = parts[0].strip('"') if parts else "INBOX"
|
||||
attr_str = parts[1].strip("()").upper() if len(parts) > 1 else "MESSAGES"
|
||||
|
||||
n = len(_get_emails()) if mailbox == "INBOX" else 0
|
||||
counts = {"MESSAGES": n, "RECENT": 0, "UNSEEN": n} if mailbox == "INBOX" \
|
||||
else {"MESSAGES": 0, "RECENT": 0, "UNSEEN": 0}
|
||||
|
||||
result_parts = []
|
||||
for attr in attr_str.split():
|
||||
if attr in counts:
|
||||
result_parts.append(f"{attr} {counts[attr]}")
|
||||
self._w(f"* STATUS {mailbox} ({' '.join(result_parts)})\r\n")
|
||||
self._w(f"{tag} OK STATUS completed\r\n")
|
||||
|
||||
def _cmd_select(self, tag: str, cmd: str, args: str) -> None:
|
||||
if self._state == "NOT_AUTHENTICATED":
|
||||
self._w(f"{tag} BAD Not authenticated\r\n")
|
||||
return
|
||||
mailbox = args.strip('"')
|
||||
emails = _get_emails()
|
||||
total = len(emails) if mailbox == "INBOX" else 0
|
||||
self._selected = mailbox
|
||||
self._state = "SELECTED"
|
||||
self._w(f"* {total} EXISTS\r\n")
|
||||
self._w(b"* 0 RECENT\r\n")
|
||||
self._w(b"* OK [UNSEEN 1] Message 1 is first unseen\r\n")
|
||||
self._w(b"* OK [UIDVALIDITY 1712345678] UIDs valid\r\n")
|
||||
self._w(f"* OK [UIDNEXT {total + 1}] Predicted next UID\r\n")
|
||||
self._w(b"* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n")
|
||||
self._w(b"* OK [PERMANENTFLAGS (\\Deleted \\Seen \\*)] Limited\r\n")
|
||||
mode = "READ-ONLY" if cmd == "EXAMINE" else "READ-WRITE"
|
||||
self._w(f"{tag} OK [{mode}] {cmd} completed\r\n")
|
||||
|
||||
def _cmd_fetch(self, tag: str, args: str, use_uid: bool) -> None:
|
||||
if self._state != "SELECTED":
|
||||
self._w(f"{tag} BAD Not in selected state\r\n")
|
||||
return
|
||||
parts = args.split(None, 1)
|
||||
range_str = parts[0] if parts else "1:*"
|
||||
items_str = parts[1] if len(parts) > 1 else "FLAGS"
|
||||
|
||||
emails = _get_emails()
|
||||
total = len(emails)
|
||||
indices = _parse_seq_range(range_str, total)
|
||||
items = _parse_fetch_items(items_str)
|
||||
# Ensure UID is included when using UID FETCH
|
||||
if use_uid and "UID" not in items:
|
||||
items = ["UID"] + items
|
||||
|
||||
for seq in indices:
|
||||
if 1 <= seq <= total:
|
||||
self._transport.write(_build_fetch_response(seq, emails[seq - 1], items))
|
||||
self._w(f"{tag} OK FETCH completed\r\n")
|
||||
|
||||
def _cmd_search(self, tag: str, uid_mode: bool = False) -> None:
|
||||
if self._state != "SELECTED":
|
||||
self._w(f"{tag} BAD Not in selected state\r\n")
|
||||
return
|
||||
nums = " ".join(str(i) for i in range(1, len(_get_emails()) + 1))
|
||||
self._w(f"* SEARCH {nums}\r\n")
|
||||
self._w(f"{tag} OK SEARCH completed\r\n")
|
||||
|
||||
def _cmd_close(self, tag: str) -> None:
|
||||
if self._state != "SELECTED":
|
||||
self._w(f"{tag} BAD Not in selected state\r\n")
|
||||
return
|
||||
self._state = "AUTHENTICATED"
|
||||
self._selected = None
|
||||
self._w(f"{tag} OK CLOSE completed\r\n")
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _w(self, data: str | bytes) -> None:
|
||||
if isinstance(data, str):
|
||||
data = data.encode()
|
||||
self._transport.write(data)
|
||||
|
||||
|
||||
async def main():
|
||||
_log("startup", msg=f"IMAP server starting as {NODE_NAME}")
|
||||
loop = asyncio.get_running_loop()
|
||||
server = await loop.create_server(IMAPProtocol, "0.0.0.0", PORT) # nosec B104
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
261
decnet/templates/imap/syslog_bridge.py
Normal file
261
decnet/templates/imap/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
|
||||
120
decnet/templates/instance_seed.py
Normal file
120
decnet/templates/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)
|
||||
26
decnet/templates/k8s/Dockerfile
Normal file
26
decnet/templates/k8s/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN pip3 install --no-cache-dir flask
|
||||
|
||||
COPY syslog_bridge.py /opt/syslog_bridge.py
|
||||
COPY server.py /opt/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 6443 8080
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER logrelay
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
3
decnet/templates/k8s/entrypoint.sh
Normal file
3
decnet/templates/k8s/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
exec python3 /opt/server.py
|
||||
120
decnet/templates/k8s/instance_seed.py
Normal file
120
decnet/templates/k8s/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)
|
||||
135
decnet/templates/k8s/server.py
Normal file
135
decnet/templates/k8s/server.py
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Kubernetes APIserver.
|
||||
Serves a fake K8s REST API on port 6443 (HTTPS-ish, plain HTTP) and 8080.
|
||||
Responds to recon endpoints (/version, /api, /apis, /api/v1/namespaces,
|
||||
/api/v1/pods) with plausible but fake data. Logs all requests as JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from flask import Flask, request
|
||||
from syslog_bridge import (
|
||||
classify_authorization,
|
||||
forward_syslog,
|
||||
syslog_line,
|
||||
write_syslog_file,
|
||||
)
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "k8s-master")
|
||||
SERVICE_NAME = "k8s"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
_VERSION = {
|
||||
"major": "1",
|
||||
"minor": "27",
|
||||
"gitVersion": "v1.27.4",
|
||||
"gitCommit": "fa3d7990104d7c1f16943a67f11b154b71f6a132",
|
||||
"gitTreeState": "clean",
|
||||
"buildDate": "2023-07-19T12:14:46Z",
|
||||
"goVersion": "go1.20.6",
|
||||
"compiler": "gc",
|
||||
"platform": "linux/amd64",
|
||||
}
|
||||
|
||||
_API_VERSIONS = {
|
||||
"kind": "APIVersions",
|
||||
"versions": ["v1"],
|
||||
"serverAddressByClientCIDRs": [{"clientCIDR": "0.0.0.0/0", "serverAddress": f"{NODE_NAME}:6443"}],
|
||||
}
|
||||
|
||||
_NAMESPACES = {
|
||||
"kind": "NamespaceList",
|
||||
"apiVersion": "v1",
|
||||
"items": [
|
||||
{"metadata": {"name": "default"}},
|
||||
{"metadata": {"name": "kube-system"}},
|
||||
{"metadata": {"name": "production"}},
|
||||
],
|
||||
}
|
||||
|
||||
_PODS = {
|
||||
"kind": "PodList",
|
||||
"apiVersion": "v1",
|
||||
"items": [
|
||||
{"metadata": {"name": "webapp-6d5f8b9-xk2p7", "namespace": "production"},
|
||||
"status": {"phase": "Running"}},
|
||||
],
|
||||
}
|
||||
|
||||
_SECRETS = {
|
||||
"kind": "Status",
|
||||
"apiVersion": "v1",
|
||||
"status": "Failure",
|
||||
"message": "secrets is forbidden: User \"system:anonymous\" cannot list resource \"secrets\"",
|
||||
"reason": "Forbidden",
|
||||
"code": 403,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
@app.before_request
|
||||
def log_request():
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
cred = classify_authorization(auth_header)
|
||||
_log(
|
||||
"request",
|
||||
method=request.method,
|
||||
path=request.path,
|
||||
remote_addr=request.remote_addr,
|
||||
auth=auth_header,
|
||||
body=request.get_data(as_text=True)[:512],
|
||||
**(cred or {}),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/version")
|
||||
def version():
|
||||
return app.response_class(json.dumps(_VERSION), mimetype="application/json")
|
||||
|
||||
|
||||
@app.route("/api")
|
||||
def api():
|
||||
return app.response_class(json.dumps(_API_VERSIONS), mimetype="application/json")
|
||||
|
||||
|
||||
@app.route("/api/v1/namespaces")
|
||||
def namespaces():
|
||||
return app.response_class(json.dumps(_NAMESPACES), mimetype="application/json")
|
||||
|
||||
|
||||
@app.route("/api/v1/pods")
|
||||
@app.route("/api/v1/namespaces/<ns>/pods")
|
||||
def pods(ns="default"):
|
||||
return app.response_class(json.dumps(_PODS), mimetype="application/json")
|
||||
|
||||
|
||||
@app.route("/api/v1/secrets")
|
||||
@app.route("/api/v1/namespaces/<ns>/secrets")
|
||||
def secrets(ns="default"):
|
||||
return app.response_class(json.dumps(_SECRETS), status=403, mimetype="application/json")
|
||||
|
||||
|
||||
@app.route("/", defaults={"path": ""})
|
||||
@app.route("/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
def catch_all(path):
|
||||
return app.response_class(
|
||||
json.dumps({"kind": "Status", "status": "Failure", "code": 404}),
|
||||
status=404,
|
||||
mimetype="application/json",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_log("startup", msg=f"Kubernetes API server starting as {NODE_NAME}")
|
||||
app.run(host="0.0.0.0", port=6443, debug=False) # nosec B104
|
||||
261
decnet/templates/k8s/syslog_bridge.py
Normal file
261
decnet/templates/k8s/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
|
||||
24
decnet/templates/ldap/Dockerfile
Normal file
24
decnet/templates/ldap/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY syslog_bridge.py /opt/syslog_bridge.py
|
||||
COPY instance_seed.py /opt/instance_seed.py
|
||||
COPY server.py /opt/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 389 636
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER logrelay
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
3
decnet/templates/ldap/entrypoint.sh
Normal file
3
decnet/templates/ldap/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
exec python3 /opt/server.py
|
||||
120
decnet/templates/ldap/instance_seed.py
Normal file
120
decnet/templates/ldap/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)
|
||||
208
decnet/templates/ldap/server.py
Normal file
208
decnet/templates/ldap/server.py
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LDAPserver.
|
||||
Parses BER-encoded BindRequest messages, logs DN and password, returns an
|
||||
invalidCredentials error. Logs all interactions as JSON.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import (
|
||||
encode_secret,
|
||||
forward_syslog,
|
||||
syslog_line,
|
||||
write_syslog_file,
|
||||
)
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "ldapserver")
|
||||
SERVICE_NAME = "ldap"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
|
||||
# RFC 4514 distinguished-name grammar: DN is a sequence of comma-separated
|
||||
# RDNs like "cn=foo,ou=people,dc=example,dc=com". Each RDN is
|
||||
# attribute=value, attribute matches [A-Za-z][A-Za-z0-9-]*. We keep this
|
||||
# check loose on value contents (commas can be escaped etc.) but tight on
|
||||
# shape, so garbage like `"abc"` or `\x00\x00` gets rejected with
|
||||
# invalidDNSyntax (34) instead of invalidCredentials (49) — that's how a
|
||||
# real OpenLDAP replies.
|
||||
_RDN_RE = re.compile(r"^[A-Za-z][A-Za-z0-9-]*=.+$")
|
||||
|
||||
|
||||
def _is_valid_dn(dn: str) -> bool:
|
||||
"""True for empty (anonymous bind) or RFC 4514-shaped DN."""
|
||||
if dn == "":
|
||||
return True
|
||||
if len(dn) > 1024:
|
||||
return False
|
||||
# Split on unescaped commas. Not perfect, but catches the obvious
|
||||
# "not a DN" inputs (missing '=' in some RDN, empty segments, etc.).
|
||||
parts: list[str] = []
|
||||
buf = ""
|
||||
escape = False
|
||||
for ch in dn:
|
||||
if escape:
|
||||
buf += ch
|
||||
escape = False
|
||||
continue
|
||||
if ch == "\\":
|
||||
buf += ch
|
||||
escape = True
|
||||
continue
|
||||
if ch == ",":
|
||||
parts.append(buf)
|
||||
buf = ""
|
||||
continue
|
||||
buf += ch
|
||||
parts.append(buf)
|
||||
return all(_RDN_RE.match(p.strip()) for p in parts)
|
||||
|
||||
|
||||
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
def _ber_length(data: bytes, pos: int):
|
||||
"""Return (length, next_pos)."""
|
||||
b = data[pos]
|
||||
if b < 0x80:
|
||||
return b, pos + 1
|
||||
n = b & 0x7f
|
||||
length = int.from_bytes(data[pos + 1:pos + 1 + n], "big")
|
||||
return length, pos + 1 + n
|
||||
|
||||
|
||||
def _ber_string(data: bytes, pos: int):
|
||||
"""Skip tag byte, read BER length, return (string, next_pos)."""
|
||||
pos += 1 # skip tag
|
||||
length, pos = _ber_length(data, pos)
|
||||
return data[pos:pos + length].decode(errors="replace"), pos + length
|
||||
|
||||
|
||||
def _parse_bind_request(msg: bytes):
|
||||
"""Best-effort extraction of (dn, password) from a raw LDAPMessage."""
|
||||
try:
|
||||
pos = 0
|
||||
# LDAPMessage SEQUENCE
|
||||
assert msg[pos] == 0x30 # nosec B101
|
||||
pos += 1
|
||||
_, pos = _ber_length(msg, pos)
|
||||
# messageID INTEGER
|
||||
assert msg[pos] == 0x02 # nosec B101
|
||||
pos += 1
|
||||
id_len, pos = _ber_length(msg, pos)
|
||||
pos += id_len
|
||||
# BindRequest [APPLICATION 0]
|
||||
assert msg[pos] == 0x60 # nosec B101
|
||||
pos += 1
|
||||
_, pos = _ber_length(msg, pos)
|
||||
# version INTEGER
|
||||
assert msg[pos] == 0x02 # nosec B101
|
||||
pos += 1
|
||||
v_len, pos = _ber_length(msg, pos)
|
||||
pos += v_len
|
||||
# name LDAPDN (OCTET STRING)
|
||||
dn, pos = _ber_string(msg, pos)
|
||||
# authentication CHOICE — simple [0] OCTET STRING
|
||||
if msg[pos] == 0x80:
|
||||
pos += 1
|
||||
pw_len, pos = _ber_length(msg, pos)
|
||||
password = msg[pos:pos + pw_len].decode(errors="replace")
|
||||
else:
|
||||
password = "<sasl_or_unknown>" # nosec B105
|
||||
return dn, password
|
||||
except Exception:
|
||||
return "<parse_error>", "<parse_error>"
|
||||
|
||||
|
||||
def _bind_error_response(message_id: int, result_code: int = 49, error_text: str = "") -> bytes:
|
||||
"""BindResponse with a configurable resultCode + diagnosticMessage.
|
||||
49 = invalidCredentials, 34 = invalidDNSyntax, 53 = unwillingToPerform."""
|
||||
err_bytes = error_text.encode()
|
||||
result_enc = bytes([0x0a, 0x01, result_code & 0xff])
|
||||
matched_dn = bytes([0x04, 0x00])
|
||||
error_msg = bytes([0x04, len(err_bytes)]) + err_bytes
|
||||
bind_resp_body = result_enc + matched_dn + error_msg
|
||||
bind_resp = bytes([0x61, len(bind_resp_body)]) + bind_resp_body
|
||||
|
||||
msg_id_enc = bytes([0x02, 0x01, message_id & 0xff])
|
||||
ldap_msg_body = msg_id_enc + bind_resp
|
||||
return bytes([0x30, len(ldap_msg_body)]) + ldap_msg_body
|
||||
|
||||
|
||||
class LDAPProtocol(asyncio.Protocol):
|
||||
def __init__(self):
|
||||
self._transport = None
|
||||
self._peer = None
|
||||
self._buf = b""
|
||||
|
||||
def connection_made(self, transport):
|
||||
self._transport = transport
|
||||
self._peer = transport.get_extra_info("peername", ("?", 0))
|
||||
_log("connect", src=self._peer[0], src_port=self._peer[1])
|
||||
|
||||
def data_received(self, data):
|
||||
self._buf += data
|
||||
self._process()
|
||||
|
||||
def _process(self):
|
||||
while len(self._buf) >= 2:
|
||||
if self._buf[0] != 0x30:
|
||||
self._buf = b""
|
||||
return
|
||||
if self._buf[1] < 0x80:
|
||||
msg_len = self._buf[1] + 2
|
||||
elif self._buf[1] == 0x81:
|
||||
if len(self._buf) < 3:
|
||||
return
|
||||
msg_len = self._buf[2] + 3
|
||||
else:
|
||||
self._buf = b""
|
||||
return
|
||||
if len(self._buf) < msg_len:
|
||||
return
|
||||
msg = self._buf[:msg_len]
|
||||
self._buf = self._buf[msg_len:]
|
||||
self._handle_message(msg)
|
||||
|
||||
def _handle_message(self, msg: bytes):
|
||||
# Extract messageID for the response
|
||||
try:
|
||||
message_id = msg[4] if len(msg) > 4 else 1
|
||||
except Exception:
|
||||
message_id = 1
|
||||
dn, password = _parse_bind_request(msg)
|
||||
_log("bind", src=self._peer[0], dn=dn, principal=dn,
|
||||
**encode_secret(password))
|
||||
_seed.jitter_sync(10, 60)
|
||||
if dn and not _is_valid_dn(dn):
|
||||
# OpenLDAP returns invalidDNSyntax (34) for malformed DNs, with
|
||||
# a diagnostic like: "invalid DN syntax". Matching that exactly
|
||||
# keeps the decoy consistent with what a scanner expects.
|
||||
self._transport.write(_bind_error_response(
|
||||
message_id, result_code=34,
|
||||
error_text="invalid DN"
|
||||
))
|
||||
else:
|
||||
self._transport.write(_bind_error_response(message_id))
|
||||
|
||||
def connection_lost(self, exc):
|
||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||
|
||||
|
||||
async def main():
|
||||
_log("startup", msg=f"LDAP server starting as {NODE_NAME}")
|
||||
loop = asyncio.get_running_loop()
|
||||
server = await loop.create_server(LDAPProtocol, "0.0.0.0", 389) # nosec B104
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
261
decnet/templates/ldap/syslog_bridge.py
Normal file
261
decnet/templates/ldap/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
|
||||
24
decnet/templates/llmnr/Dockerfile
Normal file
24
decnet/templates/llmnr/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY syslog_bridge.py /opt/syslog_bridge.py
|
||||
COPY server.py /opt/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 5355/udp
|
||||
EXPOSE 5353/udp
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER logrelay
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
3
decnet/templates/llmnr/entrypoint.sh
Normal file
3
decnet/templates/llmnr/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
exec python3 /opt/server.py
|
||||
120
decnet/templates/llmnr/instance_seed.py
Normal file
120
decnet/templates/llmnr/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)
|
||||
113
decnet/templates/llmnr/server.py
Normal file
113
decnet/templates/llmnr/server.py
Normal file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LLMNR / mDNS poisoning detector (UDP 5355 and UDP 5353).
|
||||
Listens for any incoming name-resolution queries. Any traffic here is a
|
||||
strong signal of an attacker running Responder or similar tools on the LAN.
|
||||
Logs every packet with source IP and decoded query name where possible.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import struct
|
||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "lan-host")
|
||||
SERVICE_NAME = "llmnr"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
|
||||
|
||||
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
def _decode_dns_name(data: bytes, offset: int) -> str:
|
||||
"""Decode a DNS-encoded label sequence starting at offset."""
|
||||
labels = []
|
||||
visited = set()
|
||||
pos = offset
|
||||
while pos < len(data):
|
||||
if pos in visited:
|
||||
break
|
||||
visited.add(pos)
|
||||
length = data[pos]
|
||||
if length == 0:
|
||||
break
|
||||
if length & 0xc0 == 0xc0: # pointer
|
||||
if pos + 1 >= len(data):
|
||||
break
|
||||
ptr = ((length & 0x3f) << 8) | data[pos + 1]
|
||||
labels.append(_decode_dns_name(data, ptr))
|
||||
break
|
||||
pos += 1
|
||||
labels.append(data[pos:pos + length].decode(errors="replace"))
|
||||
pos += length
|
||||
return ".".join(labels)
|
||||
|
||||
|
||||
def _parse_query(data: bytes, proto: str, src_addr) -> None:
|
||||
"""Parse DNS/LLMNR/mDNS query and log the queried name."""
|
||||
try:
|
||||
if len(data) < 12:
|
||||
raise ValueError("too short")
|
||||
flags = struct.unpack(">H", data[2:4])[0]
|
||||
qr = (flags >> 15) & 1
|
||||
qdcount = struct.unpack(">H", data[4:6])[0]
|
||||
if qr != 0 or qdcount < 1:
|
||||
return # not a query or no questions
|
||||
name = _decode_dns_name(data, 12)
|
||||
pos = 12
|
||||
while pos < len(data) and data[pos] != 0:
|
||||
pos += data[pos] + 1
|
||||
pos += 1
|
||||
qtype = struct.unpack(">H", data[pos:pos + 2])[0] if pos + 2 <= len(data) else 0
|
||||
_log(
|
||||
"query",
|
||||
proto=proto,
|
||||
src=src_addr[0],
|
||||
src_port=src_addr[1],
|
||||
name=name,
|
||||
qtype=qtype,
|
||||
)
|
||||
except Exception as e:
|
||||
_log("raw_packet", proto=proto, src=src_addr[0], data=data[:64].hex(), error=str(e))
|
||||
|
||||
|
||||
class LLMNRProtocol(asyncio.DatagramProtocol):
|
||||
def __init__(self, proto_label: str):
|
||||
self._proto = proto_label
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
_parse_query(data, self._proto, addr)
|
||||
|
||||
def error_received(self, exc):
|
||||
pass
|
||||
|
||||
|
||||
async def main():
|
||||
_log("startup", msg=f"LLMNR/mDNS server starting as {NODE_NAME}")
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# LLMNR: UDP 5355
|
||||
llmnr_transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: LLMNRProtocol("LLMNR"),
|
||||
local_addr=("0.0.0.0", 5355), # nosec B104
|
||||
)
|
||||
# mDNS: UDP 5353
|
||||
mdns_transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: LLMNRProtocol("mDNS"),
|
||||
local_addr=("0.0.0.0", 5353), # nosec B104
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.sleep(float("inf"))
|
||||
finally:
|
||||
llmnr_transport.close()
|
||||
mdns_transport.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
261
decnet/templates/llmnr/syslog_bridge.py
Normal file
261
decnet/templates/llmnr/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
|
||||
24
decnet/templates/mongodb/Dockerfile
Normal file
24
decnet/templates/mongodb/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY syslog_bridge.py /opt/syslog_bridge.py
|
||||
COPY instance_seed.py /opt/instance_seed.py
|
||||
COPY server.py /opt/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 27017
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER logrelay
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
3
decnet/templates/mongodb/entrypoint.sh
Normal file
3
decnet/templates/mongodb/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
exec python3 /opt/server.py
|
||||
120
decnet/templates/mongodb/instance_seed.py
Normal file
120
decnet/templates/mongodb/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)
|
||||
352
decnet/templates/mongodb/server.py
Normal file
352
decnet/templates/mongodb/server.py
Normal file
@@ -0,0 +1,352 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MongoDBserver.
|
||||
Implements the MongoDB wire protocol OP_MSG/OP_QUERY handshake. Responds
|
||||
to isMaster/hello, listDatabases, and authenticate commands. Logs all
|
||||
received messages as JSON.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import struct
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
|
||||
# ─── Minimal BSON walker ──────────────────────────────────────────────────────
|
||||
# Just enough to extract `saslStart` / `saslContinue` command auth fields.
|
||||
# Pulls a few BSON type codes; ignores everything else (subdocs returned
|
||||
# as raw bytes the caller can re-parse if needed). Hand-rolled rather
|
||||
# than pulling pymongo as a runtime dep — we only need 8 type codes and
|
||||
# the parser is ~40 LoC.
|
||||
|
||||
_BSON_DOUBLE = 0x01
|
||||
_BSON_STRING = 0x02
|
||||
_BSON_DOC = 0x03
|
||||
_BSON_ARRAY = 0x04
|
||||
_BSON_BINARY = 0x05
|
||||
_BSON_BOOL = 0x08
|
||||
_BSON_INT32 = 0x10
|
||||
_BSON_INT64 = 0x12
|
||||
|
||||
|
||||
def _bson_read(buf: bytes, off: int = 0) -> dict:
|
||||
"""Read a single BSON document at ``buf[off]``. Returns a dict of
|
||||
``{key: value}``. Lossy on unsupported types (silently skipped).
|
||||
Untrusted-input safe: bounds-checked, won't infinite-loop on
|
||||
malformed length fields."""
|
||||
out: dict = {}
|
||||
if off + 4 > len(buf):
|
||||
return out
|
||||
doc_len = struct.unpack_from("<i", buf, off)[0]
|
||||
end = off + doc_len
|
||||
if end > len(buf) or doc_len < 5:
|
||||
return out
|
||||
p = off + 4
|
||||
while p < end - 1: # last byte is the trailing 0x00
|
||||
t = buf[p]
|
||||
p += 1
|
||||
if t == 0:
|
||||
break
|
||||
# Read NUL-terminated cstring key.
|
||||
nul = buf.find(b"\x00", p, end)
|
||||
if nul < 0:
|
||||
break
|
||||
key = buf[p:nul].decode("utf-8", errors="replace")
|
||||
p = nul + 1
|
||||
if t == _BSON_STRING:
|
||||
if p + 4 > end:
|
||||
break
|
||||
slen = struct.unpack_from("<i", buf, p)[0]
|
||||
p += 4
|
||||
if p + slen > end or slen < 1:
|
||||
break
|
||||
out[key] = buf[p:p + slen - 1].decode("utf-8", errors="replace")
|
||||
p += slen
|
||||
elif t == _BSON_BINARY:
|
||||
if p + 5 > end:
|
||||
break
|
||||
blen = struct.unpack_from("<i", buf, p)[0]
|
||||
p += 4
|
||||
_subtype = buf[p]
|
||||
p += 1
|
||||
if p + blen > end or blen < 0:
|
||||
break
|
||||
out[key] = buf[p:p + blen] # raw bytes
|
||||
p += blen
|
||||
elif t == _BSON_INT32:
|
||||
if p + 4 > end:
|
||||
break
|
||||
out[key] = struct.unpack_from("<i", buf, p)[0]
|
||||
p += 4
|
||||
elif t == _BSON_INT64:
|
||||
if p + 8 > end:
|
||||
break
|
||||
out[key] = struct.unpack_from("<q", buf, p)[0]
|
||||
p += 8
|
||||
elif t == _BSON_BOOL:
|
||||
if p + 1 > end:
|
||||
break
|
||||
out[key] = buf[p] != 0
|
||||
p += 1
|
||||
elif t == _BSON_DOUBLE:
|
||||
p += 8
|
||||
elif t in (_BSON_DOC, _BSON_ARRAY):
|
||||
if p + 4 > end:
|
||||
break
|
||||
sub_len = struct.unpack_from("<i", buf, p)[0]
|
||||
if p + sub_len > end:
|
||||
break
|
||||
p += sub_len
|
||||
else:
|
||||
# Unsupported type — abort cleanly so we don't misalign.
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _scram_kv(payload: bytes) -> dict:
|
||||
"""Parse a SCRAM message into key=value pairs. SCRAM separates by
|
||||
commas and uses `name=value` pairs. We strip a leading `n,,` (GS2
|
||||
header) when present so the `n=username` shows up directly."""
|
||||
s = payload.decode("utf-8", errors="replace")
|
||||
if s.startswith("n,,"):
|
||||
s = s[3:]
|
||||
elif s.startswith("y,,"):
|
||||
s = s[3:]
|
||||
out: dict = {}
|
||||
for part in s.split(","):
|
||||
if "=" in part:
|
||||
k, _, v = part.partition("=")
|
||||
out[k.strip()] = v
|
||||
return out
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "mongodb")
|
||||
SERVICE_NAME = "mongodb"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
PORT = int(os.environ.get("PORT", "27017"))
|
||||
|
||||
# Per-instance (version, maxWireVersion) — paired per real MongoDB release.
|
||||
# Wire version is locked to major/minor per upstream release notes.
|
||||
_MONGO_RELEASES = [
|
||||
("4.4.22", 9),
|
||||
("5.0.25", 13),
|
||||
("6.0.5", 17),
|
||||
("6.0.14", 17),
|
||||
("7.0.5", 21),
|
||||
("7.0.8", 21),
|
||||
("7.0.11", 21),
|
||||
]
|
||||
_MONGO_VERSION, _MONGO_WIRE = _seed.pick(_MONGO_RELEASES)
|
||||
_MONGO_SET_NAME = os.environ.get("MONGO_REPL_SET", "") # empty = standalone
|
||||
|
||||
|
||||
def _new_objectid() -> bytes:
|
||||
"""12-byte BSON ObjectId — fresh per call."""
|
||||
return _seed.fresh_bytes(12)
|
||||
|
||||
# Minimal BSON helpers
|
||||
def _bson_str(key: str, val: str) -> bytes:
|
||||
k = key.encode() + b"\x00"
|
||||
v = val.encode() + b"\x00"
|
||||
return b"\x02" + k + struct.pack("<I", len(v)) + v
|
||||
|
||||
def _bson_int32(key: str, val: int) -> bytes:
|
||||
return b"\x10" + key.encode() + b"\x00" + struct.pack("<i", val)
|
||||
|
||||
def _bson_bool(key: str, val: bool) -> bytes:
|
||||
return b"\x08" + key.encode() + b"\x00" + (b"\x01" if val else b"\x00")
|
||||
|
||||
def _bson_doc(*fields: bytes) -> bytes:
|
||||
body = b"".join(fields) + b"\x00"
|
||||
return struct.pack("<I", len(body) + 4) + body
|
||||
|
||||
def _op_reply(request_id: int, doc: bytes) -> bytes:
|
||||
# OP_REPLY header: total_len(4), req_id(4), response_to(4), opcode(4)=1,
|
||||
# flags(4), cursor_id(8), starting_from(4), number_returned(4), docs
|
||||
header = struct.pack(
|
||||
"<iiiiiqii",
|
||||
16 + 20 + len(doc), # total length
|
||||
0, # request id
|
||||
request_id, # response to
|
||||
1, # OP_REPLY
|
||||
0, # flags
|
||||
0, # cursor id (int64)
|
||||
0, # starting from
|
||||
1, # number returned
|
||||
)
|
||||
return header + doc
|
||||
|
||||
def _op_msg(request_id: int, doc: bytes) -> bytes:
|
||||
payload = b"\x00" + doc
|
||||
flag_bits = struct.pack("<I", 0)
|
||||
msg_body = flag_bits + payload
|
||||
header = struct.pack("<iiii",
|
||||
16 + len(msg_body),
|
||||
1,
|
||||
request_id,
|
||||
2013,
|
||||
)
|
||||
return header + msg_body
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
class MongoDBProtocol(asyncio.Protocol):
|
||||
def __init__(self):
|
||||
self._transport = None
|
||||
self._peer = None
|
||||
self._buf = b""
|
||||
# Per-connection SCRAM state: pinned at saslStart so the
|
||||
# subsequent saslContinue's client-proof can carry the username
|
||||
# in the emitted credential row.
|
||||
self._sasl_username: str | None = None
|
||||
self._sasl_mechanism: str | None = None
|
||||
|
||||
def connection_made(self, transport):
|
||||
self._transport = transport
|
||||
self._peer = transport.get_extra_info("peername", ("?", 0))
|
||||
_log("connect", src=self._peer[0], src_port=self._peer[1])
|
||||
|
||||
def data_received(self, data):
|
||||
self._buf += data
|
||||
while len(self._buf) >= 16:
|
||||
msg_len = struct.unpack("<I", self._buf[:4])[0]
|
||||
if msg_len < 16 or msg_len > 48 * 1024 * 1024:
|
||||
self._transport.close()
|
||||
self._buf = b""
|
||||
return
|
||||
if len(self._buf) < msg_len:
|
||||
break
|
||||
msg = self._buf[:msg_len]
|
||||
self._buf = self._buf[msg_len:]
|
||||
self._handle_message(msg)
|
||||
|
||||
def _handle_message(self, msg: bytes):
|
||||
if len(msg) < 16:
|
||||
return
|
||||
request_id = struct.unpack("<I", msg[4:8])[0]
|
||||
opcode = struct.unpack("<I", msg[12:16])[0]
|
||||
_log("message", src=self._peer[0], opcode=opcode, length=len(msg))
|
||||
|
||||
# SCRAM cred capture: parse the OP_MSG body BSON looking for
|
||||
# saslStart / saslContinue. Each fires its own log event:
|
||||
# saslStart pins the username + mechanism; saslContinue emits
|
||||
# the credential row with the client-proof as secret_b64.
|
||||
if opcode == 2013 and len(msg) >= 21:
|
||||
# OP_MSG body: 4 bytes flagBits, then sections. We only
|
||||
# parse kind=0 (Body) sections — kind=1 (DocSeq) is for
|
||||
# bulk ops that don't carry SCRAM auth.
|
||||
p = 20 # 16 hdr + 4 flagBits
|
||||
while p < len(msg):
|
||||
kind = msg[p]
|
||||
p += 1
|
||||
if kind == 0: # Body section
|
||||
if p + 4 > len(msg):
|
||||
break
|
||||
doc_len = struct.unpack_from("<i", msg, p)[0]
|
||||
if p + doc_len > len(msg):
|
||||
break
|
||||
cmd = _bson_read(msg, p)
|
||||
self._handle_command(cmd)
|
||||
p += doc_len
|
||||
elif kind == 1: # DocSeq — skip
|
||||
if p + 4 > len(msg):
|
||||
break
|
||||
seq_len = struct.unpack_from("<i", msg, p)[0]
|
||||
p += seq_len
|
||||
else:
|
||||
break
|
||||
|
||||
# Build a generic isMaster-style OK response with this instance's
|
||||
# version pair. Fresh topologyVersion on every reply (matches real
|
||||
# mongod behavior — clients use this to detect failover).
|
||||
fields = [
|
||||
_bson_bool("ismaster", True),
|
||||
_bson_bool("helloOk", True),
|
||||
_bson_int32("maxBsonObjectSize", 16777216),
|
||||
_bson_int32("maxMessageSizeBytes", 48000000),
|
||||
_bson_int32("maxWriteBatchSize", 100000),
|
||||
_bson_int32("maxWireVersion", _MONGO_WIRE),
|
||||
_bson_int32("minWireVersion", 0),
|
||||
_bson_str("version", _MONGO_VERSION),
|
||||
_bson_int32("ok", 1),
|
||||
]
|
||||
if _MONGO_SET_NAME:
|
||||
fields.insert(1, _bson_str("setName", _MONGO_SET_NAME))
|
||||
reply_doc = _bson_doc(*fields)
|
||||
if opcode == 2013: # OP_MSG
|
||||
self._transport.write(_op_msg(request_id, reply_doc))
|
||||
else:
|
||||
self._transport.write(_op_reply(request_id, reply_doc))
|
||||
|
||||
def _handle_command(self, cmd: dict) -> None:
|
||||
"""Parse a single MongoDB command document for SCRAM auth.
|
||||
|
||||
saslStart — client-first-message in payload. Extract
|
||||
`n=<username>` so the next saslContinue inherits it.
|
||||
saslContinue — client-final-message in payload. Extract
|
||||
`p=<base64 client-proof>` and emit a cred row.
|
||||
"""
|
||||
# mongo's command dispatch keys off the FIRST field of the BSON
|
||||
# document. We just check key presence since dict ordering in
|
||||
# CPython 3.7+ matches insertion order.
|
||||
if "saslStart" in cmd:
|
||||
mechanism = cmd.get("mechanism")
|
||||
payload = cmd.get("payload") or b""
|
||||
if isinstance(mechanism, str):
|
||||
self._sasl_mechanism = mechanism
|
||||
if isinstance(payload, (bytes, bytearray)):
|
||||
kv = _scram_kv(bytes(payload))
|
||||
self._sasl_username = kv.get("n")
|
||||
_log("auth_start", src=self._peer[0],
|
||||
mechanism=mechanism or "?",
|
||||
username=self._sasl_username or "")
|
||||
return
|
||||
|
||||
if "saslContinue" in cmd:
|
||||
payload = cmd.get("payload") or b""
|
||||
if not isinstance(payload, (bytes, bytearray)):
|
||||
return
|
||||
kv = _scram_kv(bytes(payload))
|
||||
proof_b64 = kv.get("p")
|
||||
if not proof_b64:
|
||||
return
|
||||
try:
|
||||
proof_raw = base64.b64decode(proof_b64, validate=True)
|
||||
except (ValueError, base64.binascii.Error):
|
||||
return
|
||||
mech = (self._sasl_mechanism or "").upper()
|
||||
if "SHA-256" in mech or "SHA256" in mech:
|
||||
kind = "scram_sha256"
|
||||
elif "SHA-1" in mech or "SHA1" in mech:
|
||||
kind = "scram_sha1"
|
||||
else:
|
||||
kind = "scram_unknown"
|
||||
_log("auth", src=self._peer[0],
|
||||
username=self._sasl_username or "",
|
||||
principal=self._sasl_username,
|
||||
mechanism=self._sasl_mechanism or "",
|
||||
secret_kind=kind,
|
||||
secret_printable=proof_b64,
|
||||
secret_b64=base64.b64encode(proof_raw).decode("ascii"))
|
||||
return
|
||||
|
||||
def connection_lost(self, exc):
|
||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||
|
||||
|
||||
async def main():
|
||||
_log("startup", msg=f"MongoDB server starting as {NODE_NAME}")
|
||||
loop = asyncio.get_running_loop()
|
||||
server = await loop.create_server(MongoDBProtocol, "0.0.0.0", PORT) # nosec B104
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
261
decnet/templates/mongodb/syslog_bridge.py
Normal file
261
decnet/templates/mongodb/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
|
||||
24
decnet/templates/mqtt/Dockerfile
Normal file
24
decnet/templates/mqtt/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY syslog_bridge.py /opt/syslog_bridge.py
|
||||
COPY instance_seed.py /opt/instance_seed.py
|
||||
COPY server.py /opt/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 1883
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER logrelay
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
3
decnet/templates/mqtt/entrypoint.sh
Normal file
3
decnet/templates/mqtt/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
exec python3 /opt/server.py
|
||||
120
decnet/templates/mqtt/instance_seed.py
Normal file
120
decnet/templates/mqtt/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)
|
||||
340
decnet/templates/mqtt/server.py
Normal file
340
decnet/templates/mqtt/server.py
Normal file
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MQTT server (port 1883).
|
||||
Parses MQTT CONNECT packets, extracts client_id, etc.
|
||||
Responds with CONNACK.
|
||||
Supports dynamic topics and retained publishes.
|
||||
Logs PUBLISH commands sent by clients.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import (
|
||||
encode_secret,
|
||||
forward_syslog,
|
||||
syslog_line,
|
||||
write_syslog_file,
|
||||
)
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "mqtt-broker")
|
||||
SERVICE_NAME = "mqtt"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
PORT = int(os.environ.get("PORT", "1883"))
|
||||
|
||||
# Default to auth-required. A broker that accepts literally anyone with any
|
||||
# client_id / username was realistic for devices on a flat OT LAN pre-2018,
|
||||
# but in 2024+ it's a tell. Operators who *want* the anonymous-broker decoy
|
||||
# still set MQTT_ACCEPT_ALL=1 explicitly.
|
||||
MQTT_ACCEPT_ALL = os.environ.get("MQTT_ACCEPT_ALL", "0") == "1"
|
||||
# Optional cred list (user:pass comma-separated). If set, only those combos
|
||||
# succeed even when ACCEPT_ALL is off — lets operators plant credential bait.
|
||||
_MQTT_CREDS: set[tuple[str, str]] = set()
|
||||
for combo in os.environ.get("MQTT_CREDS", "").split(","):
|
||||
combo = combo.strip()
|
||||
if ":" in combo:
|
||||
u, _, p = combo.partition(":")
|
||||
_MQTT_CREDS.add((u, p))
|
||||
|
||||
_PERSONA_CHOICES = ["water_plant", "building_hvac", "solar_farm", "factory_line"]
|
||||
MQTT_PERSONA = os.environ.get("MQTT_PERSONA") or _seed.pick(_PERSONA_CHOICES)
|
||||
MQTT_CUSTOM_TOPICS = os.environ.get("MQTT_CUSTOM_TOPICS", "")
|
||||
|
||||
# Fleet-diverse broker ID. Real mosquitto versions in the wild right now.
|
||||
_BROKER_VERSION = os.environ.get("MQTT_BROKER_VERSION") or _seed.pick([
|
||||
"mosquitto version 1.6.9",
|
||||
"mosquitto version 2.0.11",
|
||||
"mosquitto version 2.0.15",
|
||||
"mosquitto version 2.0.17",
|
||||
"mosquitto version 2.0.18",
|
||||
"HiveMQ CE 2024.4",
|
||||
"EMQX 5.3.2",
|
||||
])
|
||||
|
||||
_CONNACK_ACCEPTED = b"\x20\x02\x00\x00"
|
||||
_CONNACK_NOT_AUTH = b"\x20\x02\x00\x05"
|
||||
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
def _read_utf8(data: bytes, pos: int):
|
||||
"""Read MQTT UTF-8 string (2-byte length prefix). Returns (string, next_pos)."""
|
||||
if pos + 2 > len(data):
|
||||
return "", pos
|
||||
length = struct.unpack(">H", data[pos:pos + 2])[0]
|
||||
pos += 2
|
||||
return data[pos:pos + length].decode(errors="replace"), pos + length
|
||||
|
||||
|
||||
def _parse_connect(payload: bytes):
|
||||
pos = 0
|
||||
proto_name, pos = _read_utf8(payload, pos)
|
||||
if pos >= len(payload):
|
||||
return {}, pos
|
||||
_proto_level = payload[pos]
|
||||
pos += 1
|
||||
if pos >= len(payload):
|
||||
return {}, pos
|
||||
flags = payload[pos]
|
||||
pos += 1
|
||||
pos += 2 # Keep alive
|
||||
client_id, pos = _read_utf8(payload, pos)
|
||||
result = {"client_id": client_id, "proto": proto_name}
|
||||
if flags & 0x04:
|
||||
_, pos = _read_utf8(payload, pos)
|
||||
_, pos = _read_utf8(payload, pos)
|
||||
if flags & 0x80:
|
||||
username, pos = _read_utf8(payload, pos)
|
||||
result["username"] = username
|
||||
if flags & 0x40:
|
||||
password, pos = _read_utf8(payload, pos)
|
||||
result["password"] = password
|
||||
return result
|
||||
|
||||
|
||||
def _parse_subscribe(payload: bytes):
|
||||
"""Returns (packet_id, [(topic, qos), ...])"""
|
||||
if len(payload) < 2:
|
||||
return 0, []
|
||||
pos = 0
|
||||
packet_id = struct.unpack(">H", payload[pos:pos+2])[0]
|
||||
pos += 2
|
||||
topics = []
|
||||
while pos < len(payload):
|
||||
topic, pos = _read_utf8(payload, pos)
|
||||
if pos >= len(payload):
|
||||
break
|
||||
qos = payload[pos] & 0x03
|
||||
pos += 1
|
||||
topics.append((topic, qos))
|
||||
return packet_id, topics
|
||||
|
||||
|
||||
def _suback(packet_id: int, granted_qos: list[int]) -> bytes:
|
||||
payload = struct.pack(">H", packet_id) + bytes(granted_qos)
|
||||
return bytes([0x90, len(payload)]) + payload
|
||||
|
||||
|
||||
def _publish(topic: str, value: str, retain: bool = True) -> bytes:
|
||||
topic_bytes = topic.encode()
|
||||
topic_len = struct.pack(">H", len(topic_bytes))
|
||||
payload = str(value).encode()
|
||||
fixed = 0x31 if retain else 0x30
|
||||
remaining = len(topic_len) + len(topic_bytes) + len(payload)
|
||||
|
||||
# variable length encoding
|
||||
rem_bytes = []
|
||||
while remaining > 0:
|
||||
encoded = remaining % 128
|
||||
remaining = remaining // 128
|
||||
if remaining > 0:
|
||||
encoded = encoded | 128
|
||||
rem_bytes.append(encoded)
|
||||
if not rem_bytes:
|
||||
rem_bytes = [0]
|
||||
|
||||
return bytes([fixed]) + bytes(rem_bytes) + topic_len + topic_bytes + payload
|
||||
|
||||
|
||||
def _parse_publish(payload: bytes, qos: int):
|
||||
pos = 0
|
||||
topic, pos = _read_utf8(payload, pos)
|
||||
packet_id = 0
|
||||
if qos > 0:
|
||||
if pos + 2 <= len(payload):
|
||||
packet_id = struct.unpack(">H", payload[pos:pos+2])[0]
|
||||
pos += 2
|
||||
data = payload[pos:]
|
||||
return topic, packet_id, data
|
||||
|
||||
|
||||
def _generate_topics() -> dict:
|
||||
topics: dict = {}
|
||||
if MQTT_CUSTOM_TOPICS:
|
||||
try:
|
||||
topics = json.loads(MQTT_CUSTOM_TOPICS)
|
||||
return topics
|
||||
except Exception as e:
|
||||
_log("config_error", severity=4, error=str(e))
|
||||
|
||||
if MQTT_PERSONA == "water_plant":
|
||||
site = _seed.pick(["north", "south", "east", "west", "plant-a", "plant-b"])
|
||||
topics.update({
|
||||
f"{site}/water/tank1/level": f"{random.uniform(60.0, 80.0):.1f}",
|
||||
f"{site}/water/tank1/pressure": f"{random.uniform(2.5, 3.0):.2f}",
|
||||
f"{site}/water/pump1/status": "RUNNING",
|
||||
f"{site}/water/pump1/rpm": f"{int(random.uniform(1400, 1450))}",
|
||||
f"{site}/water/pump2/status": "STANDBY",
|
||||
f"{site}/water/chlorine/dosing": f"{random.uniform(1.1, 1.3):.1f}",
|
||||
f"{site}/water/chlorine/residual": f"{random.uniform(0.7, 0.9):.1f}",
|
||||
f"{site}/water/valve/inlet/state": "OPEN",
|
||||
f"{site}/water/valve/drain/state": "CLOSED",
|
||||
f"{site}/alarm/high_pressure": "0",
|
||||
f"{site}/alarm/low_chlorine": "0",
|
||||
f"{site}/alarm/pump_fault": "0",
|
||||
})
|
||||
elif MQTT_PERSONA == "building_hvac":
|
||||
floor = _seed.rng.randint(1, 12)
|
||||
for i in range(_seed.rng.randint(4, 10)):
|
||||
topics[f"bldg/floor{floor}/zone{i}/temp"] = f"{random.uniform(20.0, 24.5):.1f}"
|
||||
topics[f"bldg/floor{floor}/zone{i}/setpoint"] = f"{random.uniform(21.0, 23.0):.1f}"
|
||||
topics[f"bldg/floor{floor}/ahu/status"] = _seed.pick(["RUNNING", "RUNNING", "IDLE"])
|
||||
elif MQTT_PERSONA == "solar_farm":
|
||||
for arr in range(1, _seed.rng.randint(4, 9)):
|
||||
topics[f"solar/array{arr}/power_kw"] = f"{random.uniform(40.0, 180.0):.1f}"
|
||||
topics[f"solar/array{arr}/irradiance"] = f"{random.uniform(500, 950):.0f}"
|
||||
elif MQTT_PERSONA == "factory_line":
|
||||
line = _seed.pick(["A", "B", "C"])
|
||||
for m in range(1, _seed.rng.randint(3, 7)):
|
||||
topics[f"line{line}/machine{m}/state"] = _seed.pick(["RUN", "RUN", "IDLE", "FAULT"])
|
||||
topics[f"line{line}/machine{m}/cycle_count"] = str(_seed.rng.randint(1000, 999_999))
|
||||
if not topics:
|
||||
topics = {
|
||||
"device/status": "online",
|
||||
"device/uptime": str(_seed.uptime_seconds()),
|
||||
}
|
||||
# $SYS keys match every real broker.
|
||||
topics["$SYS/broker/version"] = _BROKER_VERSION
|
||||
topics["$SYS/broker/uptime"] = f"{_seed.uptime_seconds()} seconds"
|
||||
topics["$SYS/broker/clients/connected"] = str(_seed.rng.randint(2, 24))
|
||||
return topics
|
||||
|
||||
|
||||
class MQTTProtocol(asyncio.Protocol):
|
||||
def __init__(self):
|
||||
self._transport = None
|
||||
self._peer = None
|
||||
self._buf = b""
|
||||
self._auth = False
|
||||
self._topics = _generate_topics()
|
||||
|
||||
def connection_made(self, transport):
|
||||
self._transport = transport
|
||||
self._peer = transport.get_extra_info("peername", ("?", 0))
|
||||
_log("connect", src=self._peer[0], src_port=self._peer[1])
|
||||
|
||||
def data_received(self, data):
|
||||
self._buf += data
|
||||
try:
|
||||
self._process()
|
||||
except Exception as e:
|
||||
_log("protocol_error", severity=4, error=str(e))
|
||||
if self._transport:
|
||||
self._transport.close()
|
||||
|
||||
def _process(self):
|
||||
while len(self._buf) >= 2:
|
||||
pkt_byte = self._buf[0]
|
||||
pkt_type = (pkt_byte >> 4) & 0x0f
|
||||
flags = pkt_byte & 0x0f
|
||||
qos = (flags >> 1) & 0x03
|
||||
|
||||
# Decode remaining length (variable-length encoding)
|
||||
pos = 1
|
||||
remaining = 0
|
||||
multiplier = 1
|
||||
while pos < len(self._buf):
|
||||
if pos > 4: # MQTT spec: max 4 bytes for remaining length
|
||||
self._transport.close()
|
||||
self._buf = b""
|
||||
return
|
||||
byte = self._buf[pos]
|
||||
remaining += (byte & 0x7f) * multiplier
|
||||
multiplier *= 128
|
||||
pos += 1
|
||||
if not (byte & 0x80):
|
||||
break
|
||||
else:
|
||||
return # incomplete length
|
||||
if len(self._buf) < pos + remaining:
|
||||
return # incomplete payload
|
||||
payload = self._buf[pos:pos + remaining]
|
||||
self._buf = self._buf[pos + remaining:]
|
||||
|
||||
if pkt_type == 1: # CONNECT
|
||||
info = _parse_connect(payload)
|
||||
# Migrate auth event to the universal credential SD shape
|
||||
# so the ingester's native branch picks up the row. The
|
||||
# legacy username/password keys are intentionally NOT
|
||||
# forwarded — encode_secret() supplies secret_printable
|
||||
# and secret_b64 in their place.
|
||||
_user = info.get("username", "")
|
||||
_password = info.get("password", "")
|
||||
_passthrough = {k: v for k, v in info.items()
|
||||
if k not in ("username", "password")}
|
||||
_log("auth", username=_user, principal=_user,
|
||||
**encode_secret(_password), **_passthrough)
|
||||
# Decide connection: accept-all > cred list > deny.
|
||||
cred = (info.get("username", ""), info.get("password", ""))
|
||||
accepted = (
|
||||
MQTT_ACCEPT_ALL
|
||||
or (cred in _MQTT_CREDS if _MQTT_CREDS else False)
|
||||
)
|
||||
if accepted:
|
||||
self._auth = True
|
||||
self._transport.write(_CONNACK_ACCEPTED)
|
||||
else:
|
||||
self._transport.write(_CONNACK_NOT_AUTH)
|
||||
self._transport.close()
|
||||
elif pkt_type == 8: # SUBSCRIBE
|
||||
if not self._auth:
|
||||
self._transport.close()
|
||||
continue
|
||||
packet_id, subs = _parse_subscribe(payload)
|
||||
granted_qos = [1] * len(subs) # grant QoS 1 for all
|
||||
self._transport.write(_suback(packet_id, granted_qos))
|
||||
|
||||
# Immediately send retained publishes matching topics
|
||||
for sub_topic, _ in subs:
|
||||
_log("subscribe", src=self._peer[0], topics=[sub_topic])
|
||||
for t, v in self._topics.items():
|
||||
# simple match: if topic ends with #, it matches prefix
|
||||
if sub_topic.endswith("#"):
|
||||
prefix = sub_topic[:-1]
|
||||
if t.startswith(prefix):
|
||||
self._transport.write(_publish(t, str(v)))
|
||||
elif sub_topic == t:
|
||||
self._transport.write(_publish(t, str(v)))
|
||||
|
||||
elif pkt_type == 3: # PUBLISH
|
||||
if not self._auth:
|
||||
self._transport.close()
|
||||
continue
|
||||
topic, packet_id, data = _parse_publish(payload, qos)
|
||||
# Attacker command received!
|
||||
_log("publish", src=self._peer[0], topic=topic, payload=data.decode(errors="replace"))
|
||||
|
||||
if qos == 1:
|
||||
puback = bytes([0x40, 0x02]) + struct.pack(">H", packet_id)
|
||||
self._transport.write(puback)
|
||||
|
||||
elif pkt_type == 12: # PINGREQ
|
||||
self._transport.write(b"\xd0\x00") # PINGRESP
|
||||
elif pkt_type == 14: # DISCONNECT
|
||||
self._transport.close()
|
||||
else:
|
||||
_log("packet", src=self._peer[0], pkt_type=pkt_type)
|
||||
self._transport.close()
|
||||
|
||||
def connection_lost(self, exc):
|
||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||
|
||||
|
||||
async def main():
|
||||
_log("startup", msg=f"MQTT server starting as {NODE_NAME}")
|
||||
loop = asyncio.get_running_loop()
|
||||
server = await loop.create_server(MQTTProtocol, "0.0.0.0", PORT) # nosec B104
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
261
decnet/templates/mqtt/syslog_bridge.py
Normal file
261
decnet/templates/mqtt/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
|
||||
24
decnet/templates/mssql/Dockerfile
Normal file
24
decnet/templates/mssql/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY syslog_bridge.py /opt/syslog_bridge.py
|
||||
COPY instance_seed.py /opt/instance_seed.py
|
||||
COPY server.py /opt/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 1433
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER logrelay
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
3
decnet/templates/mssql/entrypoint.sh
Normal file
3
decnet/templates/mssql/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
exec python3 /opt/server.py
|
||||
120
decnet/templates/mssql/instance_seed.py
Normal file
120
decnet/templates/mssql/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)
|
||||
217
decnet/templates/mssql/server.py
Normal file
217
decnet/templates/mssql/server.py
Normal file
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MSSQL (TDS)server.
|
||||
Reads TDS pre-login and login7 packets, extracts username, responds with
|
||||
a login failed error. Logs auth attempts as JSON.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import struct
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "dbserver")
|
||||
SERVICE_NAME = "mssql"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
|
||||
# Real SQL Server release families. Pairing (major, minor, build) makes a
|
||||
# subsequent OSQL/sqlcmd version probe line up with what MS published.
|
||||
# Builds are taken from publicly documented latest-CU numbers.
|
||||
_MSSQL_RELEASES = [
|
||||
# (name, major, minor, build, subbuild)
|
||||
("SQL Server 2016", 13, 0, 6419, 0),
|
||||
("SQL Server 2017", 14, 0, 2000, 0),
|
||||
("SQL Server 2017", 14, 0, 3460, 0),
|
||||
("SQL Server 2019", 15, 0, 2000, 0),
|
||||
("SQL Server 2019", 15, 0, 4335, 1),
|
||||
("SQL Server 2022", 16, 0, 1000, 0),
|
||||
("SQL Server 2022", 16, 0, 4115, 2),
|
||||
]
|
||||
_MSSQL_NAME, _VER_MAJ, _VER_MIN, _VER_BUILD, _VER_SUB = _seed.pick(_MSSQL_RELEASES)
|
||||
|
||||
|
||||
def _build_prelogin_response() -> bytes:
|
||||
"""TDS PRELOGIN response. Version option carries
|
||||
major(1) minor(1) build(2, network order) subbuild(2, network order)."""
|
||||
version_data = (
|
||||
bytes([_VER_MAJ & 0xff, _VER_MIN & 0xff])
|
||||
+ struct.pack(">H", _VER_BUILD & 0xffff)
|
||||
+ struct.pack(">H", _VER_SUB & 0xffff)
|
||||
)
|
||||
# Option directory + data. Offsets are from start of directory.
|
||||
# Five options: VERSION, ENCRYPTION, INSTOPT, THREADID, MARS.
|
||||
# Data fields, in order:
|
||||
encryption = b"\x02" # NOT_SUP
|
||||
instopt = b"\x00"
|
||||
threadid = struct.pack("<I", _seed.rng.randint(100, 9000))
|
||||
mars = b"\x00"
|
||||
|
||||
directory = b""
|
||||
data = b""
|
||||
# Directory header is 5 bytes per option + 1 terminator; compute offsets
|
||||
# from end of terminator.
|
||||
dir_size = 5 * 5 + 1
|
||||
running_offset = dir_size
|
||||
|
||||
def add_option(token: int, chunk: bytes) -> None:
|
||||
nonlocal directory, data, running_offset
|
||||
directory += bytes([token]) + struct.pack(">H", running_offset) + struct.pack(">H", len(chunk))
|
||||
data += chunk
|
||||
running_offset += len(chunk)
|
||||
|
||||
add_option(0x00, version_data)
|
||||
add_option(0x01, encryption)
|
||||
add_option(0x02, instopt)
|
||||
add_option(0x03, threadid)
|
||||
add_option(0x04, mars)
|
||||
directory += b"\xff"
|
||||
|
||||
payload = directory + data
|
||||
total_len = 8 + len(payload)
|
||||
header = struct.pack(">BBHBBBB", 0x04, 0x01, total_len, 0x00, 0x00, 0x01, 0x00)
|
||||
return header + payload
|
||||
|
||||
|
||||
_PRELOGIN_RESP = _build_prelogin_response()
|
||||
|
||||
|
||||
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
def _tds_error_packet(message: str) -> bytes:
|
||||
msg_enc = message.encode("utf-16-le")
|
||||
# Token type 0xAA = ERROR, followed by length, error number, state, class, msg_len, msg
|
||||
token = (
|
||||
b"\xaa"
|
||||
+ struct.pack("<H", 4 + 1 + 1 + 2 + len(msg_enc) + 1 + 1 + 1 + 1 + 4)
|
||||
+ struct.pack("<I", 18456) # SQL error number: login failed
|
||||
+ b"\x01" # state
|
||||
+ b"\x0e" # class
|
||||
+ struct.pack("<H", len(message))
|
||||
+ msg_enc
|
||||
+ b"\x00" # server name length
|
||||
+ b"\x00" # proc name length
|
||||
+ struct.pack("<I", 1) # line number
|
||||
)
|
||||
done = b"\xfd\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
payload = token + done
|
||||
header = struct.pack(">BBHBBBB", 0x04, 0x01, len(payload) + 8, 0x00, 0x00, 0x01, 0x00)
|
||||
return header + payload
|
||||
|
||||
|
||||
class MSSQLProtocol(asyncio.Protocol):
|
||||
def __init__(self):
|
||||
self._transport = None
|
||||
self._peer = None
|
||||
self._buf = b""
|
||||
self._prelogin_done = False
|
||||
|
||||
def connection_made(self, transport):
|
||||
self._transport = transport
|
||||
self._peer = transport.get_extra_info("peername", ("?", 0))
|
||||
_log("connect", src=self._peer[0], src_port=self._peer[1])
|
||||
|
||||
def data_received(self, data):
|
||||
self._buf += data
|
||||
while len(self._buf) >= 8:
|
||||
pkt_type = self._buf[0]
|
||||
pkt_len = struct.unpack(">H", self._buf[2:4])[0]
|
||||
if pkt_len < 8:
|
||||
_log("unknown_packet", src=self._peer[0], pkt_type=hex(pkt_type))
|
||||
self._transport.close()
|
||||
self._buf = b""
|
||||
return
|
||||
if len(self._buf) < pkt_len:
|
||||
break
|
||||
payload = self._buf[8:pkt_len]
|
||||
self._buf = self._buf[pkt_len:]
|
||||
self._handle_packet(pkt_type, payload)
|
||||
if self._transport.is_closing():
|
||||
self._buf = b""
|
||||
break
|
||||
|
||||
def _handle_packet(self, pkt_type: int, payload: bytes):
|
||||
if pkt_type == 0x12: # Pre-login
|
||||
self._transport.write(_PRELOGIN_RESP)
|
||||
self._prelogin_done = True
|
||||
elif pkt_type == 0x10: # Login7
|
||||
username, password = self._parse_login7_creds(payload)
|
||||
extra: dict = {}
|
||||
if password:
|
||||
pw_bytes = password.encode("utf-8")
|
||||
extra = {
|
||||
"principal": username,
|
||||
"secret_kind": "plaintext",
|
||||
"secret_printable": password,
|
||||
"secret_b64": base64.b64encode(pw_bytes).decode("ascii"),
|
||||
}
|
||||
_log("auth", src=self._peer[0], username=username, **extra)
|
||||
self._transport.write(_tds_error_packet("Login failed for user."))
|
||||
self._transport.close()
|
||||
else:
|
||||
_log("unknown_packet", src=self._peer[0], pkt_type=hex(pkt_type))
|
||||
self._transport.close()
|
||||
|
||||
@staticmethod
|
||||
def _deobfuscate_login7_password(blob: bytes) -> str:
|
||||
"""MS-TDS Login7 password obfuscation: each byte was rotated-right
|
||||
4 bits then XOR'd with 0xa5. Inverse is XOR 0xa5 then rotate-left
|
||||
4 bits (== nibble swap). Plaintext-recoverable.
|
||||
|
||||
After deobfuscation the bytes are UTF-16-LE encoded characters."""
|
||||
out = bytearray(len(blob))
|
||||
for i, b in enumerate(blob):
|
||||
x = b ^ 0xa5
|
||||
out[i] = ((x & 0x0f) << 4) | ((x & 0xf0) >> 4)
|
||||
return bytes(out).decode("utf-16-le", errors="replace")
|
||||
|
||||
def _parse_login7_creds(self, payload: bytes) -> tuple[str, str]:
|
||||
"""Login7 offset table starts at payload offset 36:
|
||||
|
||||
36-37 ibHostName 38-39 cchHostName
|
||||
40-41 ibUserName 42-43 cchUserName
|
||||
44-45 ibPassword 46-47 cchPassword
|
||||
|
||||
Both lengths are CHARACTER counts; multiply by 2 for byte length.
|
||||
The password field is XOR/swap-obfuscated — see
|
||||
:meth:`_deobfuscate_login7_password`. Plaintext-recoverable.
|
||||
"""
|
||||
try:
|
||||
if len(payload) < 48:
|
||||
return "<short_packet>", ""
|
||||
user_off, user_len = struct.unpack("<HH", payload[40:44])
|
||||
pw_off, pw_len = struct.unpack("<HH", payload[44:48])
|
||||
username = payload[user_off:user_off + user_len * 2].decode(
|
||||
"utf-16-le", errors="replace"
|
||||
)
|
||||
password = ""
|
||||
if pw_len:
|
||||
password = self._deobfuscate_login7_password(
|
||||
payload[pw_off:pw_off + pw_len * 2]
|
||||
)
|
||||
return username, password
|
||||
except Exception:
|
||||
return "<parse_error>", ""
|
||||
|
||||
def connection_lost(self, exc):
|
||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||
|
||||
|
||||
async def main():
|
||||
_log("startup", msg=f"MSSQL server starting as {NODE_NAME}")
|
||||
loop = asyncio.get_running_loop()
|
||||
server = await loop.create_server(MSSQLProtocol, "0.0.0.0", 1433) # nosec B104
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
261
decnet/templates/mssql/syslog_bridge.py
Normal file
261
decnet/templates/mssql/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
|
||||
24
decnet/templates/mysql/Dockerfile
Normal file
24
decnet/templates/mysql/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY syslog_bridge.py /opt/syslog_bridge.py
|
||||
COPY instance_seed.py /opt/instance_seed.py
|
||||
COPY server.py /opt/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 3306
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER logrelay
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
3
decnet/templates/mysql/entrypoint.sh
Normal file
3
decnet/templates/mysql/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
exec python3 /opt/server.py
|
||||
120
decnet/templates/mysql/instance_seed.py
Normal file
120
decnet/templates/mysql/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)
|
||||
167
decnet/templates/mysql/server.py
Normal file
167
decnet/templates/mysql/server.py
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MySQLserver.
|
||||
Sends a realistic MySQL 5.7 server handshake, reads the client login
|
||||
packet, extracts username, then closes with Access Denied. Logs auth
|
||||
attempts as JSON.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import itertools
|
||||
import os
|
||||
import struct
|
||||
|
||||
import instance_seed as _seed
|
||||
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "dbserver")
|
||||
SERVICE_NAME = "mysql"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
PORT = int(os.environ.get("PORT", "3306"))
|
||||
|
||||
# Per-instance version. Real fleets never run one identical point release
|
||||
# across every host — weighted mix of still-in-the-wild 5.7/8.0 builds.
|
||||
_MYSQL_VER = os.environ.get("MYSQL_VERSION") or _seed.pick_weighted([
|
||||
("5.7.38-log", 1),
|
||||
("5.7.43-log", 2),
|
||||
("5.7.44-log", 2),
|
||||
("8.0.32", 2),
|
||||
("8.0.35", 3),
|
||||
("8.0.36", 3),
|
||||
("8.0.39", 2),
|
||||
("8.0.40", 1),
|
||||
])
|
||||
|
||||
# Monotonic per-process counter for connection IDs. Seeded with a
|
||||
# per-instance base so two deckies never hand out id=1 to the same scanner.
|
||||
_CONN_ID_SEQ = itertools.count(_seed.rng.randint(17, 65_000))
|
||||
|
||||
|
||||
def _build_greeting(conn_id: int, salt: bytes) -> bytes:
|
||||
"""MySQL protocol v10 Initial Handshake Packet. salt is 20 bytes
|
||||
(8 + 12 split across two sections) and must be freshly random per
|
||||
connection — it's the challenge the client hashes its password against."""
|
||||
assert len(salt) == 20
|
||||
return (
|
||||
b"\x0a"
|
||||
+ _MYSQL_VER.encode() + b"\x00"
|
||||
+ struct.pack("<I", conn_id)
|
||||
+ salt[:8]
|
||||
+ b"\x00"
|
||||
+ b"\xff\xf7"
|
||||
+ b"\x21"
|
||||
+ b"\x02\x00"
|
||||
+ b"\xff\x81"
|
||||
+ b"\x15"
|
||||
+ b"\x00" * 10
|
||||
+ salt[8:] + b"\x00"
|
||||
+ b"mysql_native_password\x00"
|
||||
)
|
||||
|
||||
|
||||
def _make_packet(payload: bytes, seq: int = 0) -> bytes:
|
||||
length = len(payload)
|
||||
return struct.pack("<I", length)[:3] + bytes([seq]) + payload
|
||||
|
||||
|
||||
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
class MySQLProtocol(asyncio.Protocol):
|
||||
def __init__(self):
|
||||
self._transport = None
|
||||
self._peer = None
|
||||
self._buf = b""
|
||||
self._greeted = False
|
||||
self._conn_id = next(_CONN_ID_SEQ) & 0xFFFFFFFF
|
||||
# 20-byte scramble; fresh per connection so two handshakes to the
|
||||
# same decky never present identical auth challenges.
|
||||
self._salt = _seed.fresh_bytes(20)
|
||||
|
||||
def connection_made(self, transport):
|
||||
self._transport = transport
|
||||
self._peer = transport.get_extra_info("peername", ("?", 0))
|
||||
_log("connect", src=self._peer[0], src_port=self._peer[1],
|
||||
connection_id=self._conn_id)
|
||||
transport.write(_make_packet(_build_greeting(self._conn_id, self._salt), seq=0))
|
||||
self._greeted = True
|
||||
|
||||
def data_received(self, data):
|
||||
self._buf += data
|
||||
# MySQL packets: 3-byte length + 1-byte seq + payload
|
||||
while len(self._buf) >= 4:
|
||||
length = struct.unpack("<I", self._buf[:3] + b"\x00")[0]
|
||||
if length > 1024 * 1024:
|
||||
self._transport.close()
|
||||
self._buf = b""
|
||||
return
|
||||
if len(self._buf) < 4 + length:
|
||||
break
|
||||
payload = self._buf[4:4 + length]
|
||||
self._buf = self._buf[4 + length:]
|
||||
self._handle_packet(payload)
|
||||
|
||||
def _handle_packet(self, payload: bytes):
|
||||
if not payload:
|
||||
return
|
||||
# Login packet: capability flags (4), max_packet (4), charset (1),
|
||||
# reserved (23), username (NUL-terminated), auth-response.
|
||||
# mysql_native_password puts a 1-byte length followed by exactly
|
||||
# 20 bytes: SHA1(password) XOR SHA1(salt + SHA1(SHA1(password))) —
|
||||
# plaintext is unrecoverable but the 20 bytes ARE a credential the
|
||||
# attacker knew, so they land as secret_kind="mysql_native_password".
|
||||
username = "<unknown>"
|
||||
auth_response = b""
|
||||
if len(payload) > 32:
|
||||
try:
|
||||
username_start = 32
|
||||
nul = payload.index(b"\x00", username_start)
|
||||
username = payload[username_start:nul].decode(errors="replace")
|
||||
# auth-response length byte + bytes
|
||||
if len(payload) > nul + 1:
|
||||
resp_len = payload[nul + 1]
|
||||
if resp_len and len(payload) >= nul + 2 + resp_len:
|
||||
auth_response = payload[nul + 2:nul + 2 + resp_len]
|
||||
except (ValueError, IndexError):
|
||||
username = "<parse_error>"
|
||||
|
||||
extra: dict = {}
|
||||
if auth_response:
|
||||
_b64 = base64.b64encode(auth_response).decode("ascii")
|
||||
extra = {
|
||||
"principal": username,
|
||||
"secret_kind": "mysql_native_password",
|
||||
"secret_printable": auth_response.hex(),
|
||||
"secret_b64": _b64,
|
||||
}
|
||||
_log("auth", src=self._peer[0], username=username,
|
||||
connection_id=self._conn_id, **extra)
|
||||
# Real mysqld includes client IP in the error text.
|
||||
src_ip = self._peer[0] if self._peer else "?"
|
||||
msg = f"Access denied for user '{username}'@'{src_ip}' (using password: YES)"
|
||||
err = b"\xff" + struct.pack("<H", 1045) + b"#28000" + msg.encode()
|
||||
_seed.jitter_sync(15, 90)
|
||||
if self._transport and not self._transport.is_closing():
|
||||
self._transport.write(_make_packet(err, seq=2))
|
||||
self._transport.close()
|
||||
|
||||
def connection_lost(self, exc):
|
||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||
|
||||
|
||||
async def main():
|
||||
_log("startup", msg=f"MySQL server starting as {NODE_NAME}")
|
||||
loop = asyncio.get_running_loop()
|
||||
server = await loop.create_server(MySQLProtocol, "0.0.0.0", PORT) # nosec B104
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
261
decnet/templates/mysql/syslog_bridge.py
Normal file
261
decnet/templates/mysql/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
|
||||
23
decnet/templates/pop3/Dockerfile
Normal file
23
decnet/templates/pop3/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY syslog_bridge.py /opt/syslog_bridge.py
|
||||
COPY server.py /opt/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 110 995
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER logrelay
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
3
decnet/templates/pop3/entrypoint.sh
Normal file
3
decnet/templates/pop3/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
exec python3 /opt/server.py
|
||||
120
decnet/templates/pop3/instance_seed.py
Normal file
120
decnet/templates/pop3/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)
|
||||
488
decnet/templates/pop3/server.py
Normal file
488
decnet/templates/pop3/server.py
Normal file
@@ -0,0 +1,488 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
POP3 server (port 110).
|
||||
Full POP3 state machine with bait mailbox.
|
||||
|
||||
States: AUTHORIZATION → TRANSACTION
|
||||
|
||||
Credentials via IMAP_USERS env var (shared with IMAP service).
|
||||
10 bait emails containing AWS keys, DB passwords, tokens etc.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from syslog_bridge import (
|
||||
SEVERITY_WARNING,
|
||||
encode_secret,
|
||||
forward_syslog,
|
||||
syslog_line,
|
||||
write_syslog_file,
|
||||
)
|
||||
|
||||
NODE_NAME = os.environ.get("NODE_NAME", "mailserver")
|
||||
SERVICE_NAME = "pop3"
|
||||
LOG_TARGET = os.environ.get("LOG_TARGET", "")
|
||||
PORT = int(os.environ.get("PORT", "110"))
|
||||
POP3_BANNER = os.environ.get("POP3_BANNER", f"+OK {NODE_NAME} Dovecot POP3 ready.")
|
||||
_RAW_USERS = os.environ.get("IMAP_USERS", "admin:admin123,root:toor,mail:mail,user:user")
|
||||
|
||||
VALID_USERS: dict[str, str] = {
|
||||
u: p for part in _RAW_USERS.split(",") if ":" in part for u, p in [part.split(":", 1)]
|
||||
}
|
||||
|
||||
# Path to a directory of ``*.eml`` files dropped by the orchestrator
|
||||
# emailgen worker (``/var/spool/decnet-emails/`` by convention). When
|
||||
# set and populated, those EMLs replace the hardcoded fallback list
|
||||
# below — same semantics as the IMAP template. Empty / missing falls
|
||||
# back so a fresh deployment is never silent.
|
||||
_EMAIL_SEED_PATH = os.environ.get("POP3_EMAIL_SEED", "")
|
||||
_SEED_RESCAN_INTERVAL = float(os.environ.get("POP3_EMAIL_SEED_RESCAN", "5"))
|
||||
|
||||
# ── Bait emails ───────────────────────────────────────────────────────────────
|
||||
|
||||
_BAIT_EMAILS: list[str] = [
|
||||
(
|
||||
"Date: Mon, 06 Nov 2023 09:12:33 +0000\r\n"
|
||||
"From: DevOps Team <devops@company.internal>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: AWS credentials rotation\r\n"
|
||||
"Message-ID: <1@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"Team,\r\n\r\n"
|
||||
"New AWS credentials have been issued. Old keys deactivated.\r\n\r\n"
|
||||
"Access Key ID: AKIAIOSFODNN7EXAMPLE\r\n"
|
||||
"Secret Access Key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\r\n\r\n"
|
||||
"Update ~/.aws/credentials immediately.\r\n\r\n-- DevOps\r\n"
|
||||
),
|
||||
(
|
||||
"Date: Tue, 07 Nov 2023 14:05:11 +0000\r\n"
|
||||
"From: Monitoring <monitoring@company.internal>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: DB password changed\r\n"
|
||||
"Message-ID: <2@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"Production database password was rotated.\r\n\r\n"
|
||||
"Connection string: mysql://admin:Sup3rS3cr3t!@10.0.1.5:3306/production\r\n\r\n"
|
||||
"Update all app configs.\r\n"
|
||||
),
|
||||
(
|
||||
"Date: Wed, 08 Nov 2023 08:30:00 +0000\r\n"
|
||||
"From: GitHub <noreply@github.com>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: Your personal access token\r\n"
|
||||
"Message-ID: <3@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"Hi admin,\r\n\r\n"
|
||||
"A new personal access token was created for your account.\r\n\r\n"
|
||||
"Token: ghp_16C7e42F292c6912E7710c838347Ae178B4a\r\n\r\n"
|
||||
"If this wasn't you, revoke it immediately at github.com/settings/tokens.\r\n"
|
||||
),
|
||||
(
|
||||
"Date: Thu, 09 Nov 2023 11:22:47 +0000\r\n"
|
||||
"From: IT Admin <admin@company.internal>\r\n"
|
||||
"To: team@company.internal\r\n"
|
||||
"Subject: VPN config attached\r\n"
|
||||
"Message-ID: <4@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"VPN access details for new starters:\r\n\r\n"
|
||||
" Host: vpn.company.internal:1194\r\n"
|
||||
" Protocol: UDP\r\n"
|
||||
" Username: vpnadmin\r\n"
|
||||
" Password: VpnP@ss2024\r\n\r\n"
|
||||
"Config file sent separately via secure channel.\r\n"
|
||||
),
|
||||
(
|
||||
"Date: Fri, 10 Nov 2023 16:45:00 +0000\r\n"
|
||||
"From: SysAdmin <sysadmin@company.internal>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: Root password\r\n"
|
||||
"Message-ID: <5@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"New root password for prod servers:\r\n\r\n"
|
||||
" r00tM3T00!\r\n\r\n"
|
||||
"Change after first login. Do NOT forward this email.\r\n"
|
||||
),
|
||||
(
|
||||
"Date: Sat, 11 Nov 2023 03:12:04 +0000\r\n"
|
||||
"From: Backup System <backup@company.internal>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: Backup job failed\r\n"
|
||||
"Message-ID: <6@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"Nightly backup to 192.168.1.50:/mnt/nas FAILED at 03:11 UTC.\r\n\r\n"
|
||||
"Error: Authentication failed. Credentials in /etc/backup.conf may be stale.\r\n\r\n"
|
||||
"Last successful backup: 2023-11-10 03:11 UTC\r\n"
|
||||
),
|
||||
(
|
||||
"Date: Sun, 12 Nov 2023 07:04:31 +0000\r\n"
|
||||
"From: Security Alerts <alerts@company.internal>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: SSH brute-force alert\r\n"
|
||||
"Message-ID: <7@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"47 failed SSH login attempts detected against prod-web-01.\r\n\r\n"
|
||||
"Source IPs: 185.220.101.34, 185.220.101.47, 185.220.101.52\r\n"
|
||||
"Target user: root\r\n"
|
||||
"Period: 2023-11-12 06:58 - 07:04 UTC\r\n\r\n"
|
||||
"All attempts blocked by fail2ban. No successful logins.\r\n"
|
||||
),
|
||||
(
|
||||
"Date: Mon, 13 Nov 2023 10:11:55 +0000\r\n"
|
||||
"From: External Vendor <vendor@external.com>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: RE: API integration\r\n"
|
||||
"Message-ID: <8@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"Hi,\r\n\r\n"
|
||||
"Here is the live API key for the integration:\r\n\r\n"
|
||||
" sk_live_9mK3xF2aP7qR1bN8cT4dW6vE0yU5hJ\r\n\r\n"
|
||||
"Keep this confidential. Let me know if you need the webhook secret.\r\n\r\n"
|
||||
"Best regards,\r\nVendor Support\r\n"
|
||||
),
|
||||
(
|
||||
"Date: Tue, 14 Nov 2023 13:48:22 +0000\r\n"
|
||||
"From: Help Desk <helpdesk@company.internal>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: Password reset request\r\n"
|
||||
"Message-ID: <9@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"Hi,\r\n\r\n"
|
||||
"Could you reset my MFA? Current password is Winter2024! so you can verify it's me.\r\n\r\n"
|
||||
"Thanks\r\n"
|
||||
),
|
||||
(
|
||||
"Date: Wed, 15 Nov 2023 00:01:00 +0000\r\n"
|
||||
"From: AWS Billing <noreply@aws.amazon.com>\r\n"
|
||||
"To: admin@company.internal\r\n"
|
||||
"Subject: Your AWS bill is ready\r\n"
|
||||
"Message-ID: <10@company.internal>\r\n"
|
||||
"\r\n"
|
||||
"Your AWS bill for October 2023 is $847.23.\r\n\r\n"
|
||||
"Top services:\r\n"
|
||||
" EC2 (us-east-1): $412.10\r\n"
|
||||
" RDS (us-east-1): $198.50\r\n"
|
||||
" S3: $87.43\r\n"
|
||||
" EC2 (eu-west-2): $149.20\r\n\r\n"
|
||||
"Account ID: 123456789012\r\n"
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ── Spool-backed email loader ─────────────────────────────────────────────────
|
||||
# POP3 stores each message as a single str (full RFC 822 text); when the
|
||||
# emailgen spool is configured, we read every *.eml in it and serve the
|
||||
# raw bytes as the corpus. Same caching strategy as the IMAP template.
|
||||
|
||||
_seed_cache: list[str] | None = None
|
||||
_seed_cache_dir_mtime: float = 0.0
|
||||
_seed_cache_loaded_at: float = 0.0
|
||||
|
||||
|
||||
def _scan_seed_dir(path: Path) -> list[str]:
|
||||
"""Walk *path* recursively and return each .eml's raw text content,
|
||||
sorted by mtime so older threads get lower indices."""
|
||||
eml_paths: list[Path] = []
|
||||
try:
|
||||
for p in path.rglob("*.eml"):
|
||||
if p.is_file():
|
||||
eml_paths.append(p)
|
||||
except OSError:
|
||||
return []
|
||||
eml_paths.sort(key=lambda p: p.stat().st_mtime)
|
||||
out: list[str] = []
|
||||
for p in eml_paths:
|
||||
try:
|
||||
out.append(p.read_text(encoding="utf-8", errors="replace"))
|
||||
except OSError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _get_emails() -> list[str]:
|
||||
"""Return the active corpus. Same fallback rules as IMAP template."""
|
||||
global _seed_cache, _seed_cache_dir_mtime, _seed_cache_loaded_at
|
||||
if not _EMAIL_SEED_PATH:
|
||||
return _BAIT_EMAILS
|
||||
seed_dir = Path(_EMAIL_SEED_PATH)
|
||||
try:
|
||||
dir_stat = seed_dir.stat()
|
||||
except OSError:
|
||||
return _BAIT_EMAILS
|
||||
now = time.monotonic()
|
||||
fresh_enough = (
|
||||
_seed_cache is not None
|
||||
and (now - _seed_cache_loaded_at) < _SEED_RESCAN_INTERVAL
|
||||
and dir_stat.st_mtime == _seed_cache_dir_mtime
|
||||
)
|
||||
if fresh_enough:
|
||||
return _seed_cache or _BAIT_EMAILS
|
||||
scanned = _scan_seed_dir(seed_dir)
|
||||
if not scanned:
|
||||
return _BAIT_EMAILS
|
||||
_seed_cache = scanned
|
||||
_seed_cache_dir_mtime = dir_stat.st_mtime
|
||||
_seed_cache_loaded_at = now
|
||||
return scanned
|
||||
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
# ── Protocol ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class POP3Protocol(asyncio.Protocol):
|
||||
def __init__(self):
|
||||
self._transport = None
|
||||
self._peer = ("?", 0)
|
||||
self._buf = b""
|
||||
self._state = "AUTHORIZATION"
|
||||
self._current_user: str | None = None
|
||||
self._deleted: set[int] = set() # 0-based indices of DELE'd messages
|
||||
|
||||
def connection_made(self, transport):
|
||||
self._transport = transport
|
||||
self._peer = transport.get_extra_info("peername", ("?", 0))
|
||||
_log("connect", src=self._peer[0], src_port=self._peer[1])
|
||||
banner = POP3_BANNER if POP3_BANNER.endswith("\r\n") else POP3_BANNER + "\r\n"
|
||||
if not banner.startswith("+OK"):
|
||||
banner = "+OK " + banner
|
||||
transport.write(banner.encode())
|
||||
|
||||
def data_received(self, data):
|
||||
self._buf += data
|
||||
while b"\n" in self._buf:
|
||||
line, self._buf = self._buf.split(b"\n", 1)
|
||||
self._handle_line(line.decode(errors="replace").strip())
|
||||
|
||||
def connection_lost(self, exc):
|
||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||
|
||||
# ── Command dispatch ──────────────────────────────────────────────────────
|
||||
|
||||
def _handle_line(self, line: str) -> None:
|
||||
parts = line.split(None, 1)
|
||||
if not parts:
|
||||
return
|
||||
cmd = parts[0].upper()
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
_log("command", src=self._peer[0], cmd=cmd, state=self._state)
|
||||
|
||||
# Always available
|
||||
if cmd == "CAPA":
|
||||
self._transport.write(
|
||||
b"+OK\r\nTOP\r\nUSER\r\nUIDL\r\nRESP-CODES\r\nAUTH-RESP-CODE\r\nSASL\r\n.\r\n"
|
||||
)
|
||||
elif cmd == "QUIT":
|
||||
self._transport.write(b"+OK Logging out.\r\n")
|
||||
self._transport.close()
|
||||
|
||||
# AUTHORIZATION state
|
||||
elif cmd == "USER":
|
||||
self._cmd_user(args)
|
||||
elif cmd == "PASS":
|
||||
self._cmd_pass(args)
|
||||
|
||||
# TRANSACTION state
|
||||
elif cmd == "STAT":
|
||||
self._cmd_stat()
|
||||
elif cmd == "LIST":
|
||||
self._cmd_list(args)
|
||||
elif cmd == "RETR":
|
||||
self._cmd_retr(args)
|
||||
elif cmd == "TOP":
|
||||
self._cmd_top(args)
|
||||
elif cmd == "UIDL":
|
||||
self._cmd_uidl(args)
|
||||
elif cmd == "DELE":
|
||||
self._cmd_dele(args)
|
||||
elif cmd == "RSET":
|
||||
self._cmd_rset()
|
||||
elif cmd == "NOOP":
|
||||
self._transport.write(b"+OK\r\n")
|
||||
|
||||
else:
|
||||
self._transport.write(b"-ERR Command not recognized\r\n")
|
||||
|
||||
# ── Command implementations ───────────────────────────────────────────────
|
||||
|
||||
def _cmd_user(self, args: str) -> None:
|
||||
if self._state != "AUTHORIZATION":
|
||||
self._transport.write(b"-ERR Already authenticated\r\n")
|
||||
return
|
||||
self._current_user = args.strip()
|
||||
self._transport.write(b"+OK User name accepted, password please\r\n")
|
||||
|
||||
def _cmd_pass(self, args: str) -> None:
|
||||
if self._state != "AUTHORIZATION":
|
||||
self._transport.write(b"-ERR Already authenticated\r\n")
|
||||
return
|
||||
if not self._current_user:
|
||||
self._transport.write(b"-ERR USER required first\r\n")
|
||||
return
|
||||
username = self._current_user
|
||||
password = args.strip()
|
||||
_enc = encode_secret(password)
|
||||
if VALID_USERS.get(username) == password:
|
||||
self._state = "TRANSACTION"
|
||||
_log("auth", src=self._peer[0], username=username, principal=username,
|
||||
outcome="success", **_enc)
|
||||
self._transport.write(b"+OK Logged in.\r\n")
|
||||
else:
|
||||
_log("auth", src=self._peer[0], username=username, principal=username,
|
||||
outcome="failure", severity=SEVERITY_WARNING, **_enc)
|
||||
self._current_user = None
|
||||
self._transport.write(b"-ERR Authentication failed.\r\n")
|
||||
|
||||
def _require_transaction(self) -> bool:
|
||||
if self._state != "TRANSACTION":
|
||||
self._transport.write(b"-ERR Not authenticated\r\n")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _active_messages(self) -> list[tuple[int, str]]:
|
||||
"""Return [(1-based-num, body), ...] excluding DELE'd messages."""
|
||||
return [
|
||||
(i + 1, body)
|
||||
for i, body in enumerate(_get_emails())
|
||||
if i not in self._deleted
|
||||
]
|
||||
|
||||
def _cmd_stat(self) -> None:
|
||||
if not self._require_transaction():
|
||||
return
|
||||
msgs = self._active_messages()
|
||||
total = sum(len(b.encode()) for _, b in msgs)
|
||||
self._transport.write(f"+OK {len(msgs)} {total}\r\n".encode())
|
||||
|
||||
def _cmd_list(self, args: str) -> None:
|
||||
if not self._require_transaction():
|
||||
return
|
||||
emails = _get_emails()
|
||||
if args:
|
||||
try:
|
||||
n = int(args)
|
||||
idx = n - 1
|
||||
if idx in self._deleted or not (0 <= idx < len(emails)):
|
||||
self._transport.write(b"-ERR No such message\r\n")
|
||||
else:
|
||||
size = len(emails[idx].encode())
|
||||
self._transport.write(f"+OK {n} {size}\r\n".encode())
|
||||
except ValueError:
|
||||
self._transport.write(b"-ERR Invalid argument\r\n")
|
||||
else:
|
||||
msgs = self._active_messages()
|
||||
total = sum(len(b.encode()) for _, b in msgs)
|
||||
self._transport.write(f"+OK {len(msgs)} messages ({total} octets)\r\n".encode())
|
||||
for n, body in msgs:
|
||||
self._transport.write(f"{n} {len(body.encode())}\r\n".encode())
|
||||
self._transport.write(b".\r\n")
|
||||
|
||||
def _cmd_retr(self, args: str) -> None:
|
||||
if not self._require_transaction():
|
||||
return
|
||||
try:
|
||||
n = int(args)
|
||||
idx = n - 1
|
||||
emails = _get_emails()
|
||||
if idx in self._deleted or not (0 <= idx < len(emails)):
|
||||
self._transport.write(b"-ERR No such message\r\n")
|
||||
return
|
||||
body = emails[idx]
|
||||
raw = body.encode()
|
||||
_log("retr", src=self._peer[0], message_num=n)
|
||||
self._transport.write(f"+OK {len(raw)} octets\r\n".encode())
|
||||
self._transport.write(raw)
|
||||
if not raw.endswith(b"\r\n"):
|
||||
self._transport.write(b"\r\n")
|
||||
self._transport.write(b".\r\n")
|
||||
except ValueError:
|
||||
self._transport.write(b"-ERR Invalid argument\r\n")
|
||||
|
||||
def _cmd_top(self, args: str) -> None:
|
||||
if not self._require_transaction():
|
||||
return
|
||||
try:
|
||||
parts = args.split(None, 1)
|
||||
n = int(parts[0])
|
||||
line_count = int(parts[1]) if len(parts) > 1 else 0
|
||||
idx = n - 1
|
||||
emails = _get_emails()
|
||||
if idx in self._deleted or not (0 <= idx < len(emails)):
|
||||
self._transport.write(b"-ERR No such message\r\n")
|
||||
return
|
||||
body = emails[idx]
|
||||
sep = "\r\n\r\n"
|
||||
if sep in body:
|
||||
headers, rest = body.split(sep, 1)
|
||||
headers += sep
|
||||
else:
|
||||
headers, rest = body, ""
|
||||
body_lines = rest.split("\r\n")[:line_count]
|
||||
result = headers + "\r\n".join(body_lines)
|
||||
self._transport.write(b"+OK\r\n")
|
||||
self._transport.write(result.encode())
|
||||
if not result.endswith("\r\n"):
|
||||
self._transport.write(b"\r\n")
|
||||
self._transport.write(b".\r\n")
|
||||
except (ValueError, IndexError):
|
||||
self._transport.write(b"-ERR Invalid arguments\r\n")
|
||||
|
||||
def _cmd_uidl(self, args: str) -> None:
|
||||
if not self._require_transaction():
|
||||
return
|
||||
if args:
|
||||
try:
|
||||
n = int(args)
|
||||
idx = n - 1
|
||||
if idx in self._deleted or not (0 <= idx < len(_get_emails())):
|
||||
self._transport.write(b"-ERR No such message\r\n")
|
||||
else:
|
||||
self._transport.write(f"+OK {n} msg-{n}\r\n".encode())
|
||||
except ValueError:
|
||||
self._transport.write(b"-ERR Invalid argument\r\n")
|
||||
else:
|
||||
self._transport.write(b"+OK\r\n")
|
||||
for n, _ in self._active_messages():
|
||||
self._transport.write(f"{n} msg-{n}\r\n".encode())
|
||||
self._transport.write(b".\r\n")
|
||||
|
||||
def _cmd_dele(self, args: str) -> None:
|
||||
if not self._require_transaction():
|
||||
return
|
||||
try:
|
||||
n = int(args)
|
||||
idx = n - 1
|
||||
if idx in self._deleted or not (0 <= idx < len(_get_emails())):
|
||||
self._transport.write(b"-ERR No such message\r\n")
|
||||
else:
|
||||
self._deleted.add(idx)
|
||||
_log("delete", src=self._peer[0], message_num=n)
|
||||
self._transport.write(f"+OK Message {n} deleted\r\n".encode())
|
||||
except ValueError:
|
||||
self._transport.write(b"-ERR Invalid argument\r\n")
|
||||
|
||||
def _cmd_rset(self) -> None:
|
||||
if not self._require_transaction():
|
||||
return
|
||||
self._deleted.clear()
|
||||
self._transport.write(b"+OK\r\n")
|
||||
|
||||
|
||||
async def main():
|
||||
_log("startup", msg=f"POP3 server starting as {NODE_NAME}")
|
||||
loop = asyncio.get_running_loop()
|
||||
server = await loop.create_server(POP3Protocol, "0.0.0.0", PORT) # nosec B104
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
261
decnet/templates/pop3/syslog_bridge.py
Normal file
261
decnet/templates/pop3/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
|
||||
24
decnet/templates/postgres/Dockerfile
Normal file
24
decnet/templates/postgres/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
ARG BASE_IMAGE=debian:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY syslog_bridge.py /opt/syslog_bridge.py
|
||||
COPY instance_seed.py /opt/instance_seed.py
|
||||
COPY server.py /opt/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 5432
|
||||
RUN useradd -r -s /bin/false -d /opt logrelay \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends libcap2-bin \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (find /usr/bin/ -maxdepth 1 -name 'python3*' -type f -exec setcap 'cap_net_bind_service+eip' {} \; 2>/dev/null || true)
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD kill -0 1 || exit 1
|
||||
|
||||
USER logrelay
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
3
decnet/templates/postgres/entrypoint.sh
Normal file
3
decnet/templates/postgres/entrypoint.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
exec python3 /opt/server.py
|
||||
120
decnet/templates/postgres/instance_seed.py
Normal file
120
decnet/templates/postgres/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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user