feat(templates): per-instance stealth via instance_seed in service servers

Every service template now pulls version strings, cluster/node UUIDs, auth
salts, greeting banners, and uptime from the seeded per-instance RNG instead
of hard-coded defaults. Scanners sweeping the fleet now see legitimately
diverging fingerprints per decky while each decky's own responses stay
internally consistent across restarts.

Covers elasticsearch, ftp, http, https, ldap, mongodb, mqtt, mssql, mysql,
postgres, redis, and smtp templates.
This commit is contained in:
2026-04-22 09:24:16 -04:00
parent 51e9e263ca
commit 3fb84ac5d0
12 changed files with 755 additions and 156 deletions

View File

@@ -7,38 +7,111 @@ KEYS, and arbitrary commands. Logs every command and argument as JSON.
import asyncio
import os
import instance_seed as _seed
from syslog_bridge import syslog_line, write_syslog_file, forward_syslog
NODE_NAME = os.environ.get("NODE_NAME", "cache-server")
SERVICE_NAME = "redis"
LOG_TARGET = os.environ.get("LOG_TARGET", "")
PORT = int(os.environ.get("PORT", "6379"))
_REDIS_VER = os.environ.get("REDIS_VERSION", "7.2.7")
_REDIS_OS = os.environ.get("REDIS_OS", "Linux 5.15.0")
_INFO = (
f"# Server\n"
f"redis_version:{_REDIS_VER}\n"
f"redis_mode:standalone\n"
f"os:{_REDIS_OS}\n"
f"arch_bits:64\n"
f"tcp_port:6379\n"
f"uptime_in_seconds:864000\n"
f"connected_clients:1\n"
f"# Keyspace\n"
).encode()
# Per-instance realistic version pick (weighted toward still-supported lines).
_REDIS_VER = os.environ.get("REDIS_VERSION") or _seed.pick_weighted([
("7.2.4", 2), ("7.2.5", 3), ("7.2.6", 3), ("7.2.7", 2),
("7.0.15", 2), ("7.0.14", 1),
("6.2.14", 2), ("6.2.16", 1),
])
# Kernel line matching plausible Debian/Ubuntu LTS minor ranges.
_REDIS_OS = os.environ.get("REDIS_OS") or _seed.pick([
"Linux 5.15.0-118-generic x86_64",
"Linux 6.1.0-21-amd64 x86_64",
"Linux 5.10.0-30-amd64 x86_64",
"Linux 6.5.0-27-generic x86_64",
])
_RUN_ID = _seed.instance_hex(20, "redis-run")
_PROCESS_ID = _seed.rng.randint(120, 32000)
_TCP_PORT_STR = str(PORT)
_FAKE_STORE = {
b"sessions:user:1234": b'{"id":1234,"user":"admin","token":"eyJhbGciOiJIUzI1NiJ9..."}',
b"sessions:user:5678": b'{"id":5678,"user":"alice","token":"eyJhbGciOiJIUzI1NiJ9..."}',
b"cache:api_key": b"sk_live_9mK3xF2aP7qR1bN8cT4dW6vE0yU5hJ",
b"jwt:secret": b"super_secret_jwt_signing_key_do_not_share_2024",
b"user:admin": b'{"username":"admin","password":"$2b$12$LQv3c1yqBWVHxkd0LHAkC.","role":"superadmin"}',
b"user:alice": b'{"username":"alice","password":"$2b$12$XKLDm3vT8nPqR4sY2hE6fO","role":"user"}',
b"config:db_password": b"Pr0dDB!2024#Secure",
b"config:aws_access_key": b"AKIAIOSFODNN7EXAMPLE",
b"config:aws_secret_key": b"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
b"rate_limit:192.168.1.1": b"42",
# AUTH config: empty REDIS_PASSWORD means "no auth configured" — AUTH returns
# the canonical "Client sent AUTH, but no password is set" error, matching a
# real redis-server with requirepass unset.
_REQUIREPASS = os.environ.get("REDIS_PASSWORD", "")
def _info_block() -> bytes:
uptime = _seed.uptime_seconds()
uptime_days = max(1, uptime // 86400)
# Minimal but plausible subset; real redis INFO has ~150 keys.
text = (
"# Server\r\n"
f"redis_version:{_REDIS_VER}\r\n"
f"redis_git_sha1:00000000\r\n"
f"redis_git_dirty:0\r\n"
f"redis_build_id:{_seed.instance_hex(8, 'redis-build')}\r\n"
"redis_mode:standalone\r\n"
f"os:{_REDIS_OS}\r\n"
"arch_bits:64\r\n"
f"process_id:{_PROCESS_ID}\r\n"
f"run_id:{_RUN_ID}\r\n"
f"tcp_port:{_TCP_PORT_STR}\r\n"
f"uptime_in_seconds:{uptime}\r\n"
f"uptime_in_days:{uptime_days}\r\n"
"hz:10\r\n"
"# Clients\r\n"
"connected_clients:1\r\n"
"maxclients:10000\r\n"
"# Memory\r\n"
f"used_memory:{_seed.rng.randint(800_000, 12_000_000)}\r\n"
"mem_fragmentation_ratio:1.12\r\n"
"# Stats\r\n"
f"total_connections_received:{_seed.rng.randint(50, 9000)}\r\n"
f"total_commands_processed:{_seed.rng.randint(5_000, 2_000_000)}\r\n"
"# Keyspace\r\n"
)
return text.encode()
def _build_fake_store() -> dict[bytes, bytes]:
"""Per-instance plausible cache content. No embedded DECNET-identifying
strings; keys / values shaped like what real apps leave in redis."""
n_sessions = _seed.rng.randint(3, 14)
store: dict[bytes, bytes] = {}
app_slug = _seed.pick(["api", "web", "worker", "shop", "admin", "cms"])
env_slug = _seed.pick(["prod", "stage", "live"])
for i in range(n_sessions):
sid = _seed.instance_hex(16, f"sess-{i}")
uid = _seed.rng.randint(1000, 999_999)
store[f"session:{sid}".encode()] = (
f'{{"uid":{uid},"exp":{int(_seed.boot_epoch()) + 86400 * 7}}}'
).encode()
for i in range(_seed.rng.randint(2, 6)):
store[f"cache:{app_slug}:feed:{i}".encode()] = (
_seed.instance_hex(24, f"feed-{i}").encode()
)
store[f"stats:{app_slug}:{env_slug}:requests".encode()] = (
str(_seed.rng.randint(5_000, 900_000)).encode()
)
return store
_FAKE_STORE = _build_fake_store()
# Config presented via CONFIG GET — realistic subset of a default redis.conf.
_CONFIG = {
"maxmemory": "0",
"maxmemory-policy": "noeviction",
"maxclients": "10000",
"timeout": "0",
"tcp-keepalive": "300",
"databases": "16",
"save": "3600 1 300 100 60 10000",
"appendonly": "no",
"loglevel": "notice",
"dir": "/var/lib/redis",
"bind": "127.0.0.1 -::1",
"protected-mode": "yes",
"supervised": "systemd",
}
@@ -114,11 +187,22 @@ class RESPParser:
return line.split(), end + (2 if buf[end:end + 2] == b"\r\n" else 1)
def _config_get(pattern: str) -> bytes:
"""Emulate `CONFIG GET <pattern>` — returns alternating key/value bulks."""
import fnmatch
matches = [(k, v) for k, v in _CONFIG.items() if fnmatch.fnmatchcase(k, pattern)]
out = f"*{len(matches) * 2}\r\n".encode()
for k, v in matches:
out += _bulk(k) + _bulk(v)
return out
class RedisProtocol(asyncio.Protocol):
def __init__(self):
self._transport = None
self._peer = None
self._parser = RESPParser()
self._authed = not _REQUIREPASS # auth satisfied iff no password set
def connection_made(self, transport):
self._transport = transport
@@ -129,6 +213,16 @@ class RedisProtocol(asyncio.Protocol):
for cmd in self._parser.feed(data):
self._handle_command(cmd)
def _write(self, payload: bytes) -> None:
"""Writes with per-response jitter. Unseeded so two connections to
the same decky don't get an identical latency fingerprint. Honeypot
throughput targets are low; a few ms of blocking sleep here is fine
and avoids the asyncio-task plumbing the synchronous protocol model
doesn't otherwise need."""
_seed.jitter_sync(2, 40)
if self._transport and not self._transport.is_closing():
self._transport.write(payload)
def _handle_command(self, parts):
if not parts:
return
@@ -137,15 +231,40 @@ class RedisProtocol(asyncio.Protocol):
_log("command", src=self._peer[0], cmd=verb, args=args[:8])
if verb == "AUTH":
password = args[0] if args else ""
password = args[-1] if args else ""
_log("auth", src=self._peer[0], password=password)
self._transport.write(b"+OK\r\n")
elif verb == "INFO":
self._transport.write(f"${len(_INFO)}\r\n".encode() + _INFO + b"\r\n")
if not _REQUIREPASS:
self._write(
_err("Client sent AUTH, but no password is set. "
"Did you mean AUTH <username> <password>?")
)
elif password == _REQUIREPASS:
self._authed = True
self._write(b"+OK\r\n")
else:
self._write(_err("WRONGPASS invalid username-password pair or user is disabled."))
return
if not self._authed:
self._write(_err("NOAUTH Authentication required."))
return
if verb == "INFO":
info = _info_block()
self._write(f"${len(info)}\r\n".encode() + info + b"\r\n")
elif verb == "PING":
self._transport.write(b"+PONG\r\n")
self._write(b"+PONG\r\n")
elif verb == "CONFIG":
self._transport.write(b"*0\r\n")
sub = args[0].upper() if args else ""
if sub == "GET" and len(args) >= 2:
self._write(_config_get(args[1]))
elif sub == "SET":
self._write(b"+OK\r\n")
elif sub == "RESETSTAT":
self._write(b"+OK\r\n")
else:
self._write(_err(
"Unknown CONFIG subcommand or wrong number of arguments for '"
f"{sub.lower() or '?'}'"
))
elif verb == "KEYS":
pattern = args[0] if args else "*"
keys = list(_FAKE_STORE.keys())
@@ -157,26 +276,35 @@ class RedisProtocol(asyncio.Protocol):
keys = [k for k in keys if k == pat]
resp = f"*{len(keys)}\r\n".encode() + b"".join(_bulk(k.decode()) for k in keys)
self._transport.write(resp)
self._write(resp)
elif verb == "GET":
key = args[0].encode() if args else b""
if key in _FAKE_STORE:
self._transport.write(_bulk(_FAKE_STORE[key].decode()))
self._write(_bulk(_FAKE_STORE[key].decode()))
else:
self._transport.write(b"$-1\r\n")
self._write(b"$-1\r\n")
elif verb == "SCAN":
keys = list(_FAKE_STORE.keys())
resp = b"*2\r\n$1\r\n0\r\n" + f"*{len(keys)}\r\n".encode() + b"".join(_bulk(k.decode()) for k in keys)
self._transport.write(resp)
self._write(resp)
elif verb == "TYPE":
self._transport.write(b"+string\r\n")
self._write(b"+string\r\n")
elif verb == "TTL":
self._transport.write(b":-1\r\n")
self._write(b":-1\r\n")
elif verb == "DBSIZE":
self._write(f":{len(_FAKE_STORE)}\r\n".encode())
elif verb == "COMMAND":
self._write(b"*0\r\n")
elif verb == "CLIENT":
self._write(b"+OK\r\n")
elif verb == "SELECT":
self._write(b"+OK\r\n")
elif verb == "QUIT":
self._transport.write(b"+OK\r\n")
self._transport.close()
self._write(b"+OK\r\n")
if self._transport:
self._transport.close()
else:
self._transport.write(_err("unknown command"))
self._write(_err(f"unknown command '{verb.lower()}'"))
def connection_lost(self, exc):
_log("disconnect", src=self._peer[0] if self._peer else "?")