diff --git a/decnet/services/https.py b/decnet/services/https.py new file mode 100644 index 0000000..3734651 --- /dev/null +++ b/decnet/services/https.py @@ -0,0 +1,59 @@ +import json +from pathlib import Path +from decnet.services.base import BaseService + +TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "https" + + +class HTTPSService(BaseService): + name = "https" + ports = [443] + default_image = "build" + + def compose_fragment( + self, + decky_name: str, + log_target: str | None = None, + service_cfg: dict | None = None, + ) -> dict: + cfg = service_cfg or {} + fragment: dict = { + "build": {"context": str(TEMPLATES_DIR)}, + "container_name": f"{decky_name}-https", + "restart": "unless-stopped", + "environment": { + "NODE_NAME": decky_name, + }, + } + if log_target: + fragment["environment"]["LOG_TARGET"] = log_target + + # Optional persona overrides — only injected when explicitly set + if "server_header" in cfg: + fragment["environment"]["SERVER_HEADER"] = cfg["server_header"] + if "response_code" in cfg: + fragment["environment"]["RESPONSE_CODE"] = str(cfg["response_code"]) + if "fake_app" in cfg: + fragment["environment"]["FAKE_APP"] = cfg["fake_app"] + if "extra_headers" in cfg: + val = cfg["extra_headers"] + fragment["environment"]["EXTRA_HEADERS"] = ( + json.dumps(val) if isinstance(val, dict) else val + ) + if "custom_body" in cfg: + fragment["environment"]["CUSTOM_BODY"] = cfg["custom_body"] + if "files" in cfg: + files_path = str(Path(cfg["files"]).resolve()) + fragment["environment"]["FILES_DIR"] = "/opt/html_files" + fragment.setdefault("volumes", []).append(f"{files_path}:/opt/html_files:ro") + if "tls_cert" in cfg: + fragment["environment"]["TLS_CERT"] = cfg["tls_cert"] + if "tls_key" in cfg: + fragment["environment"]["TLS_KEY"] = cfg["tls_key"] + if "tls_cn" in cfg: + fragment["environment"]["TLS_CN"] = cfg["tls_cn"] + + return fragment + + def dockerfile_context(self) -> Path | None: + return TEMPLATES_DIR diff --git a/templates/https/Dockerfile b/templates/https/Dockerfile new file mode 100644 index 0000000..02d3d74 --- /dev/null +++ b/templates/https/Dockerfile @@ -0,0 +1,29 @@ +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 decnet_logging.py /opt/decnet_logging.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 decnet \ + && chown -R decnet:decnet /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 decnet +ENTRYPOINT ["/entrypoint.sh"] diff --git a/templates/https/decnet_logging.py b/templates/https/decnet_logging.py new file mode 100644 index 0000000..5a09505 --- /dev/null +++ b/templates/https/decnet_logging.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Services call syslog_line() to format an RFC 5424 message, then +write_syslog_file() to emit it to stdout — Docker captures it, and the +host-side collector streams it into the log file. + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +from datetime import datetime, timezone +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@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 (decky 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 write_syslog_file(line: str) -> None: + """Emit a syslog line to stdout for Docker log capture.""" + print(line, flush=True) + + +def forward_syslog(line: str, log_target: str) -> None: + """No-op stub. TCP forwarding is now handled by rsyslog, not by service containers.""" + pass diff --git a/templates/https/entrypoint.sh b/templates/https/entrypoint.sh new file mode 100644 index 0000000..4301922 --- /dev/null +++ b/templates/https/entrypoint.sh @@ -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 diff --git a/templates/https/server.py b/templates/https/server.py new file mode 100644 index 0000000..450f17a --- /dev/null +++ b/templates/https/server.py @@ -0,0 +1,136 @@ +#!/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 +from decnet_logging import syslog_line, write_syslog_file, forward_syslog + +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_HEADER = os.environ.get("SERVER_HEADER", "Apache/2.4.54 (Debian)") +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": ( + "\n" + "Apache2 Debian Default Page\n" + "

Apache2 Debian Default Page

\n" + "

It works!

" + ), + "nginx_default": ( + "Welcome to nginx!\n" + "

Welcome to nginx!

\n" + "

If you see this page, the nginx web server is successfully installed.

\n" + "" + ), + "wordpress": ( + "WordPress › Error\n" + "
\n" + "

Error establishing a database connection

" + ), + "phpmyadmin": ( + "phpMyAdmin\n" + "
\n" + "\n" + "\n" + "
" + ), + "iis_default": ( + "IIS Windows Server\n" + "

IIS Windows Server

\n" + "

Welcome to Internet Information Services

" + ), +} + +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(): + _log( + "request", + method=request.method, + path=request.path, + remote_addr=request.remote_addr, + headers=dict(request.headers), + body=request.get_data(as_text=True)[:512], + ) + + +@app.route("/", defaults={"path": ""}) +@app.route("/", 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 = ( + "\n" + "\n" + "403 Forbidden\n" + "\n" + "

Forbidden

\n" + "

You don't have permission to access this resource.

\n" + "
\n" + f"
{SERVER_HEADER} Server at {NODE_NAME} Port 443
\n" + "\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() diff --git a/tests/live/test_https_live.py b/tests/live/test_https_live.py new file mode 100644 index 0000000..e586476 --- /dev/null +++ b/tests/live/test_https_live.py @@ -0,0 +1,190 @@ +import os +import queue +import socket +import ssl +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +import pytest +import requests +from urllib3.exceptions import InsecureRequestWarning + +from tests.live.conftest import assert_rfc5424 + +_REPO_ROOT = Path(__file__).parent.parent.parent +_TEMPLATES = _REPO_ROOT / "templates" +_VENV_PYTHON = _REPO_ROOT / ".venv" / "bin" / "python" +_PYTHON = str(_VENV_PYTHON) if _VENV_PYTHON.exists() else sys.executable + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _wait_for_tls_port(port: int, timeout: float = 10.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + with socket.create_connection(("127.0.0.1", port), timeout=0.5) as sock: + with ctx.wrap_socket(sock, server_hostname="127.0.0.1"): + return True + except (OSError, ssl.SSLError): + time.sleep(0.1) + return False + + +def _drain(q: queue.Queue, timeout: float = 2.0) -> list[str]: + lines: list[str] = [] + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + lines.append(q.get(timeout=max(0.01, deadline - time.monotonic()))) + except queue.Empty: + break + return lines + + +def _generate_self_signed_cert(cert_path: str, key_path: str) -> None: + subprocess.run( + [ + "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", key_path, "-out", cert_path, + "-days", "1", "-subj", "/CN=localhost", + ], + check=True, + capture_output=True, + ) + + +class _HTTPSServiceProcess: + """Manages an HTTPS service subprocess with TLS cert generation.""" + + def __init__(self, port: int, cert_path: str, key_path: str): + template_dir = _TEMPLATES / "https" + env = { + **os.environ, + "NODE_NAME": "test-node", + "PORT": str(port), + "PYTHONPATH": str(template_dir), + "LOG_TARGET": "", + "TLS_CERT": cert_path, + "TLS_KEY": key_path, + } + self._proc = subprocess.Popen( + [_PYTHON, str(template_dir / "server.py")], + cwd=str(template_dir), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + text=True, + ) + self._q: queue.Queue = queue.Queue() + self._reader = threading.Thread(target=self._read_loop, daemon=True) + self._reader.start() + + def _read_loop(self) -> None: + assert self._proc.stdout is not None + for line in self._proc.stdout: + self._q.put(line.rstrip("\n")) + + def drain(self, timeout: float = 2.0) -> list[str]: + return _drain(self._q, timeout) + + def stop(self) -> None: + self._proc.terminate() + try: + self._proc.wait(timeout=3) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc.wait() + + +@pytest.fixture +def https_service(): + """Start an HTTPS server with a temporary self-signed cert.""" + started: list[_HTTPSServiceProcess] = [] + tmp_dirs: list[tempfile.TemporaryDirectory] = [] + + def _start() -> tuple[int, callable]: + port = _free_port() + tmp = tempfile.TemporaryDirectory() + tmp_dirs.append(tmp) + cert_path = os.path.join(tmp.name, "cert.pem") + key_path = os.path.join(tmp.name, "key.pem") + _generate_self_signed_cert(cert_path, key_path) + + svc = _HTTPSServiceProcess(port, cert_path, key_path) + started.append(svc) + if not _wait_for_tls_port(port): + svc.stop() + pytest.fail(f"HTTPS service did not bind to port {port} within 10s") + svc.drain(timeout=0.3) + return port, svc.drain + + yield _start + + for svc in started: + svc.stop() + for tmp in tmp_dirs: + tmp.cleanup() + + +@pytest.mark.live +class TestHTTPSLive: + def test_get_request_logged(self, https_service): + port, drain = https_service() + resp = requests.get( + f"https://127.0.0.1:{port}/admin", timeout=5, verify=False, + ) + assert resp.status_code == 403 + lines = drain() + assert_rfc5424(lines, service="https", event_type="request") + + def test_tls_handshake(self, https_service): + port, drain = https_service() + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + with socket.create_connection(("127.0.0.1", port), timeout=5) as sock: + with ctx.wrap_socket(sock, server_hostname="127.0.0.1") as tls: + assert tls.version() is not None + + def test_server_header_set(self, https_service): + port, drain = https_service() + resp = requests.get( + f"https://127.0.0.1:{port}/", timeout=5, verify=False, + ) + assert "Server" in resp.headers + assert resp.headers["Server"] != "" + + def test_post_body_logged(self, https_service): + port, drain = https_service() + requests.post( + f"https://127.0.0.1:{port}/login", + data={"username": "admin", "password": "secret"}, + timeout=5, + verify=False, + ) + lines = drain() + assert any("body=" in line for line in lines if "request" in line), ( + "Expected 'body=' in request log line. Got:\n" + "\n".join(lines[:10]) + ) + + def test_method_and_path_in_log(self, https_service): + port, drain = https_service() + requests.get( + f"https://127.0.0.1:{port}/secret/file.txt", timeout=5, verify=False, + ) + lines = drain() + matched = assert_rfc5424(lines, service="https", event_type="request") + assert "GET" in matched or 'method="GET"' in matched + assert "/secret/file.txt" in matched or 'path="/secret/file.txt"' in matched