From 77ceb9d6f3cf6feec434605552fd4b7e3e0a457b Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 29 Apr 2026 12:23:56 -0400 Subject: [PATCH] feat(services): config schemas for the rest of the registry + textarea base64 transport - Declarative config_schema on RDP, Telnet, MySQL, Redis, SMTP, SMTP_Relay matching the keys each service already reads at compose time. - TODO marker on the 19 services that accept service_cfg but never read it, so future contributors know where to plug schemas in. - Wizard base64-wraps all textarea values at INI emit (DeckyFleet buildIni); validate_cfg detects the b64: sentinel and decodes back to UTF-8. Plain raw strings still pass through for direct API submitters. - HTTPS image entrypoint accepts PEM content or path in TLS_CERT/TLS_KEY: detects a BEGIN header, writes content to /opt/tls/, and re-exports the on-disk path so server.py keeps reading paths. - Tests cover schema/compose alignment for each new service plus textarea base64 round-trip (incl. UTF-8) and HTTPS PEM end-to-end. --- decnet/services/base.py | 20 +++- decnet/services/conpot.py | 1 + decnet/services/docker_api.py | 1 + decnet/services/elasticsearch.py | 1 + decnet/services/ftp.py | 1 + decnet/services/imap.py | 1 + decnet/services/k8s.py | 1 + decnet/services/ldap.py | 1 + decnet/services/llmnr.py | 1 + decnet/services/mongodb.py | 1 + decnet/services/mqtt.py | 1 + decnet/services/mssql.py | 1 + decnet/services/mysql.py | 12 ++- decnet/services/pop3.py | 1 + decnet/services/postgres.py | 1 + decnet/services/rdp.py | 15 ++- decnet/services/redis.py | 19 +++- decnet/services/sip.py | 1 + decnet/services/smb.py | 1 + decnet/services/smtp.py | 20 +++- decnet/services/smtp_relay.py | 20 +++- decnet/services/sniffer.py | 1 + decnet/services/snmp.py | 1 + decnet/services/telnet.py | 23 +++- decnet/services/tftp.py | 1 + decnet/services/vnc.py | 1 + decnet/templates/https/entrypoint.sh | 26 ++++- decnet_web/src/components/DeckyFleet.tsx | 28 +++-- tests/services/test_config_schema.py | 127 +++++++++++++++++++++++ 29 files changed, 312 insertions(+), 17 deletions(-) diff --git a/decnet/services/base.py b/decnet/services/base.py index ed6074d3..942813d2 100644 --- a/decnet/services/base.py +++ b/decnet/services/base.py @@ -1,8 +1,16 @@ +import base64 +import binascii from abc import ABC, abstractmethod from dataclasses import asdict, dataclass from pathlib import Path from typing import Any, Literal +# Sentinel prefix used by the deploy wizard to ship multi-line textarea values +# through ConfigParser without relying on its multi-line continuation syntax. +# Plain raw values without the prefix are accepted as-is so direct API +# submitters (PUT /…/services/{svc}/config) keep working with raw strings. +TEXTAREA_B64_PREFIX = "b64:" + FieldType = Literal["string", "password", "int", "bool", "textarea", "enum"] @@ -105,8 +113,18 @@ class BaseService(ABC): def _coerce(spec: ServiceConfigField, raw: Any) -> Any: t = spec.type - if t in ("string", "password", "textarea"): + if t in ("string", "password"): return str(raw) + if t == "textarea": + s = str(raw) + if s.startswith(TEXTAREA_B64_PREFIX): + try: + return base64.b64decode(s[len(TEXTAREA_B64_PREFIX):], validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError) as e: + raise ConfigValidationError( + f"{spec.key}: malformed {TEXTAREA_B64_PREFIX} payload" + ) from e + return s if t == "int": try: return int(raw) diff --git a/decnet/services/conpot.py b/decnet/services/conpot.py index 5eacff6a..a4750483 100644 --- a/decnet/services/conpot.py +++ b/decnet/services/conpot.py @@ -12,6 +12,7 @@ class ConpotService(BaseService): name = "conpot" ports = [502, 161, 80] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: env = { diff --git a/decnet/services/docker_api.py b/decnet/services/docker_api.py index d4db39c4..e58c8e34 100644 --- a/decnet/services/docker_api.py +++ b/decnet/services/docker_api.py @@ -8,6 +8,7 @@ class DockerAPIService(BaseService): name = "docker_api" ports = [2375, 2376] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/services/elasticsearch.py b/decnet/services/elasticsearch.py index d4bb65c2..b08c9a0d 100644 --- a/decnet/services/elasticsearch.py +++ b/decnet/services/elasticsearch.py @@ -9,6 +9,7 @@ class ElasticsearchService(BaseService): name = "elasticsearch" ports = [9200] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/services/ftp.py b/decnet/services/ftp.py index 0a1cafe3..d9059aac 100644 --- a/decnet/services/ftp.py +++ b/decnet/services/ftp.py @@ -8,6 +8,7 @@ class FTPService(BaseService): name = "ftp" ports = [21] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/services/imap.py b/decnet/services/imap.py index 902f57cf..86de699d 100644 --- a/decnet/services/imap.py +++ b/decnet/services/imap.py @@ -8,6 +8,7 @@ class IMAPService(BaseService): name = "imap" ports = [143, 993] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/services/k8s.py b/decnet/services/k8s.py index 32cc56db..47e988fc 100644 --- a/decnet/services/k8s.py +++ b/decnet/services/k8s.py @@ -8,6 +8,7 @@ class KubernetesAPIService(BaseService): name = "k8s" ports = [6443, 8080] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/services/ldap.py b/decnet/services/ldap.py index 76eaa2d0..6d7337b7 100644 --- a/decnet/services/ldap.py +++ b/decnet/services/ldap.py @@ -8,6 +8,7 @@ class LDAPService(BaseService): name = "ldap" ports = [389, 636] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/services/llmnr.py b/decnet/services/llmnr.py index 43197376..9e4a7b0e 100644 --- a/decnet/services/llmnr.py +++ b/decnet/services/llmnr.py @@ -15,6 +15,7 @@ class LLMNRService(BaseService): name = "llmnr" ports = [5355, 5353] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/services/mongodb.py b/decnet/services/mongodb.py index 397faaf3..050d10b0 100644 --- a/decnet/services/mongodb.py +++ b/decnet/services/mongodb.py @@ -8,6 +8,7 @@ class MongoDBService(BaseService): name = "mongodb" ports = [27017] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/services/mqtt.py b/decnet/services/mqtt.py index 60d134f5..b2260b71 100644 --- a/decnet/services/mqtt.py +++ b/decnet/services/mqtt.py @@ -8,6 +8,7 @@ class MQTTService(BaseService): name = "mqtt" ports = [1883] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/services/mssql.py b/decnet/services/mssql.py index 46b262dc..4b8528e3 100644 --- a/decnet/services/mssql.py +++ b/decnet/services/mssql.py @@ -8,6 +8,7 @@ class MSSQLService(BaseService): name = "mssql" ports = [1433] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/services/mysql.py b/decnet/services/mysql.py index deb5b502..639a2d08 100644 --- a/decnet/services/mysql.py +++ b/decnet/services/mysql.py @@ -1,5 +1,5 @@ from pathlib import Path -from decnet.services.base import BaseService +from decnet.services.base import BaseService, ServiceConfigField TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "mysql" @@ -9,6 +9,16 @@ class MySQLService(BaseService): ports = [3306] default_image = "build" + config_schema = [ + ServiceConfigField( + key="version", + label="Advertised MySQL version", + type="string", + placeholder="8.0.36", + help="Sets the version banner the fake MySQL handshake reports.", + ), + ] + def compose_fragment( self, decky_name: str, diff --git a/decnet/services/pop3.py b/decnet/services/pop3.py index 58e33ad1..6fd57a38 100644 --- a/decnet/services/pop3.py +++ b/decnet/services/pop3.py @@ -8,6 +8,7 @@ class POP3Service(BaseService): name = "pop3" ports = [110, 995] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/services/postgres.py b/decnet/services/postgres.py index 8a75ded0..62bf83df 100644 --- a/decnet/services/postgres.py +++ b/decnet/services/postgres.py @@ -8,6 +8,7 @@ class PostgresService(BaseService): name = "postgres" ports = [5432] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/services/rdp.py b/decnet/services/rdp.py index ccdced01..c3c45ce2 100644 --- a/decnet/services/rdp.py +++ b/decnet/services/rdp.py @@ -1,5 +1,5 @@ from pathlib import Path -from decnet.services.base import BaseService +from decnet.services.base import BaseService, ServiceConfigField TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "rdp" @@ -9,6 +9,19 @@ class RDPService(BaseService): ports = [3389] default_image = "build" + config_schema = [ + ServiceConfigField( + key="nla", + label="Enable CredSSP / NLA", + type="bool", + default=False, + help=( + "Off by default — basic X.224 cookie capture is enough for most " + "attacker traffic and avoids the openssl cert-gen at container start." + ), + ), + ] + def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { "build": {"context": str(TEMPLATES_DIR)}, diff --git a/decnet/services/redis.py b/decnet/services/redis.py index b6c9b5c9..36ddec76 100644 --- a/decnet/services/redis.py +++ b/decnet/services/redis.py @@ -1,5 +1,5 @@ from pathlib import Path -from decnet.services.base import BaseService +from decnet.services.base import BaseService, ServiceConfigField TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "redis" @@ -9,6 +9,23 @@ class RedisService(BaseService): ports = [6379] default_image = "build" + config_schema = [ + ServiceConfigField( + key="version", + label="Advertised Redis version", + type="string", + placeholder="7.2.4", + help="Reported by INFO server -> redis_version.", + ), + ServiceConfigField( + key="os_string", + label="Advertised OS string", + type="string", + placeholder="Linux 5.15.0 x86_64", + help="Reported by INFO server -> os.", + ), + ] + def compose_fragment( self, decky_name: str, diff --git a/decnet/services/sip.py b/decnet/services/sip.py index 05665396..27eb5c5d 100644 --- a/decnet/services/sip.py +++ b/decnet/services/sip.py @@ -8,6 +8,7 @@ class SIPService(BaseService): name = "sip" ports = [5060] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/services/smb.py b/decnet/services/smb.py index f6a43caa..ae7ea185 100644 --- a/decnet/services/smb.py +++ b/decnet/services/smb.py @@ -8,6 +8,7 @@ class SMBService(BaseService): name = "smb" ports = [445, 139] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/services/smtp.py b/decnet/services/smtp.py index b2005ab7..266e4f47 100644 --- a/decnet/services/smtp.py +++ b/decnet/services/smtp.py @@ -1,7 +1,7 @@ import os from pathlib import Path -from decnet.services.base import BaseService +from decnet.services.base import BaseService, ServiceConfigField TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "smtp" ARTIFACTS_ROOT = os.environ.get("DECNET_ARTIFACTS_ROOT", "/var/lib/decnet/artifacts") @@ -16,6 +16,24 @@ class SMTPService(BaseService): ports = [25, 587] default_image = "build" + config_schema = [ + ServiceConfigField( + key="banner", + label="SMTP greeting banner", + type="string", + placeholder="mail.corp.local ESMTP Postfix", + help="First line returned on TCP connect (220 ...).", + ), + ServiceConfigField( + key="mta", + label="MTA persona", + type="enum", + enum=["postfix", "exim", "sendmail"], + default="postfix", + help="Shapes EHLO capability list and error wording.", + ), + ] + def compose_fragment( self, decky_name: str, diff --git a/decnet/services/smtp_relay.py b/decnet/services/smtp_relay.py index 929ccedc..e2bb8ae8 100644 --- a/decnet/services/smtp_relay.py +++ b/decnet/services/smtp_relay.py @@ -1,7 +1,7 @@ import os from pathlib import Path -from decnet.services.base import BaseService +from decnet.services.base import BaseService, ServiceConfigField # Reuses the same template as the smtp service — only difference is # SMTP_OPEN_RELAY=1 in the environment, which enables the open relay persona. @@ -18,6 +18,24 @@ class SMTPRelayService(BaseService): ports = [25, 587] default_image = "build" + config_schema = [ + ServiceConfigField( + key="banner", + label="SMTP greeting banner", + type="string", + placeholder="mail.corp.local ESMTP Postfix", + help="First line returned on TCP connect (220 ...).", + ), + ServiceConfigField( + key="mta", + label="MTA persona", + type="enum", + enum=["postfix", "exim", "sendmail"], + default="postfix", + help="Shapes EHLO capability list and error wording.", + ), + ] + def compose_fragment( self, decky_name: str, diff --git a/decnet/services/sniffer.py b/decnet/services/sniffer.py index 5a12ea63..40a8b343 100644 --- a/decnet/services/sniffer.py +++ b/decnet/services/sniffer.py @@ -16,6 +16,7 @@ class SnifferService(BaseService): name = "sniffer" ports: list[int] = [] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads fleet_singleton = True def compose_fragment( diff --git a/decnet/services/snmp.py b/decnet/services/snmp.py index 0e67ce84..9ce6424e 100644 --- a/decnet/services/snmp.py +++ b/decnet/services/snmp.py @@ -8,6 +8,7 @@ class SNMPService(BaseService): name = "snmp" ports = [161] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/services/telnet.py b/decnet/services/telnet.py index 6bb03a2d..6ffef68c 100644 --- a/decnet/services/telnet.py +++ b/decnet/services/telnet.py @@ -1,7 +1,7 @@ import os from pathlib import Path -from decnet.services.base import BaseService +from decnet.services.base import BaseService, ServiceConfigField TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "telnet" ARTIFACTS_ROOT = os.environ.get("DECNET_ARTIFACTS_ROOT", "/var/lib/decnet/artifacts") @@ -24,6 +24,27 @@ class TelnetService(BaseService): ports = [23] default_image = "build" + config_schema = [ + ServiceConfigField( + key="password", + label="Root password", + type="password", + default="admin", + secret=True, + help="Plaintext root password for the in-container telnetd.", + ), + ServiceConfigField( + key="hostname", + label="Container hostname", + type="string", + placeholder="e.g. mail-01.corp.local", + help=( + "Cosmetic override for the telnet banner — keeps decoys " + "looking heterogeneous. Decky identity (NODE_NAME) is unaffected." + ), + ), + ] + def compose_fragment( self, decky_name: str, diff --git a/decnet/services/tftp.py b/decnet/services/tftp.py index a51ba7fc..75cbd94f 100644 --- a/decnet/services/tftp.py +++ b/decnet/services/tftp.py @@ -8,6 +8,7 @@ class TFTPService(BaseService): name = "tftp" ports = [69] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/services/vnc.py b/decnet/services/vnc.py index 0c5834e2..239765be 100644 --- a/decnet/services/vnc.py +++ b/decnet/services/vnc.py @@ -8,6 +8,7 @@ class VNCService(BaseService): name = "vnc" ports = [5900] default_image = "build" + # config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: fragment: dict = { diff --git a/decnet/templates/https/entrypoint.sh b/decnet/templates/https/entrypoint.sh index 43019223..f88a889e 100644 --- a/decnet/templates/https/entrypoint.sh +++ b/decnet/templates/https/entrypoint.sh @@ -2,12 +2,28 @@ set -e TLS_DIR="/opt/tls" -CERT="${TLS_CERT:-$TLS_DIR/cert.pem}" -KEY="${TLS_KEY:-$TLS_DIR/key.pem}" +mkdir -p "$TLS_DIR" + +# TLS_CERT/TLS_KEY may arrive as either a host-side path OR raw PEM +# content (the wizard ships PEM textareas as decoded strings). Detect by +# looking for a PEM header; if present, write to disk and rebind the var +# to the on-disk path. +if [ -n "$TLS_CERT" ] && printf '%s' "$TLS_CERT" | grep -q 'BEGIN '; then + printf '%s' "$TLS_CERT" > "$TLS_DIR/cert.pem" + CERT="$TLS_DIR/cert.pem" +else + CERT="${TLS_CERT:-$TLS_DIR/cert.pem}" +fi +if [ -n "$TLS_KEY" ] && printf '%s' "$TLS_KEY" | grep -q 'BEGIN '; then + printf '%s' "$TLS_KEY" > "$TLS_DIR/key.pem" + chmod 600 "$TLS_DIR/key.pem" + KEY="$TLS_DIR/key.pem" +else + KEY="${TLS_KEY:-$TLS_DIR/key.pem}" +fi # 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" \ @@ -15,4 +31,8 @@ if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then 2>/dev/null fi +# server.py reads TLS_CERT/TLS_KEY as filesystem paths. +export TLS_CERT="$CERT" +export TLS_KEY="$KEY" + exec python3 /opt/server.py diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx index 8c364a79..b3284d40 100644 --- a/decnet_web/src/components/DeckyFleet.tsx +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -437,11 +437,20 @@ const PLACEHOLDER_LINES = ( `[OK] ${count} deckies online — fleet size now ${fleetSize + count}`, ]; +// UTF-8-safe base64 encode (btoa alone breaks on non-ASCII). +const _b64encodeUtf8 = (s: string): string => { + const bytes = new TextEncoder().encode(s); + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin); +}; + const _buildIni = ( prefix: string, count: number, fleetSize: number, mode: PickMode, archetype: Archetype | null, services: string[], mutate: boolean, mutateEvery: number, serviceConfigs: Record>, + serviceSchemas: Record, ): string => { const lines: string[] = []; for (let i = 0; i < count; i++) { @@ -461,14 +470,19 @@ const _buildIni = ( for (const svc of services) { const cfg = serviceConfigs[svc]; if (!cfg || Object.keys(cfg).length === 0) continue; + const fieldTypes: Record = {}; + for (const f of serviceSchemas[svc] ?? []) fieldTypes[f.key] = f.type; lines.push(`[${prefix}.${svc}]`); for (const [k, v] of Object.entries(cfg)) { - // INI values can't carry literal newlines; collapse multi-line - // values (PEM textareas etc.) to \n escapes. Single-line values - // are unaffected; multi-line consumers must re-expand. - const serialised = typeof v === 'string' - ? v.replace(/\r?\n/g, '\\n') - : String(v); + // textarea values may contain newlines that ConfigParser can't carry + // on a single line; wrap them in `b64:` so validate_cfg decodes back + // to the original UTF-8 string. Other types are emitted raw. + let serialised: string; + if (fieldTypes[k] === 'textarea' && typeof v === 'string') { + serialised = `b64:${_b64encodeUtf8(v)}`; + } else { + serialised = typeof v === 'string' ? v : String(v); + } lines.push(`${k}=${serialised}`); } lines.push(''); @@ -596,7 +610,7 @@ const DeployWizard: React.FC = ({ : selectedServices; const ini = _buildIni( prefix, count, fleetSize, pickMode, archetype, servicesForIni, - mutate, mutateEvery, rolled, + mutate, mutateEvery, rolled, serviceSchemas, ); try { const res = await api.post<{ failures?: { name: string; reason: string }[] }>( diff --git a/tests/services/test_config_schema.py b/tests/services/test_config_schema.py index bc796c70..cf4b838e 100644 --- a/tests/services/test_config_schema.py +++ b/tests/services/test_config_schema.py @@ -1,5 +1,7 @@ """Schema-driven service config: descriptors, validation, compose round-trip.""" +import base64 + import pytest from decnet.services.base import ( @@ -9,7 +11,13 @@ from decnet.services.base import ( ) from decnet.services.http import HTTPService from decnet.services.https import HTTPSService +from decnet.services.mysql import MySQLService +from decnet.services.rdp import RDPService +from decnet.services.redis import RedisService +from decnet.services.smtp import SMTPService +from decnet.services.smtp_relay import SMTPRelayService from decnet.services.ssh import SSHService +from decnet.services.telnet import TelnetService class _Dummy(BaseService): @@ -130,3 +138,122 @@ def test_https_schema_includes_tls_fields(): assert {"tls_cn", "tls_cert", "tls_key"} <= keys secrets = {f.key for f in HTTPSService.config_schema if f.secret} assert {"tls_cert", "tls_key"} <= secrets + + +# --- Schemas added in this batch -------------------------------------------- + + +def test_telnet_schema_keys_match_compose_reads(): + assert {f.key for f in TelnetService.config_schema} == {"password", "hostname"} + + +def test_telnet_compose_round_trip(): + svc = TelnetService() + cfg = svc.validate_cfg({"password": "hunter2", "hostname": "mail-01"}) + frag = svc.compose_fragment("decky-test", service_cfg=cfg) + env = frag["environment"] + assert env["TELNET_ROOT_PASSWORD"] == "hunter2" + assert env["TELNET_HOSTNAME"] == "mail-01" + + +def test_rdp_schema_matches_and_bool_coerces(): + assert {f.key for f in RDPService.config_schema} == {"nla"} + svc = RDPService() + cfg = svc.validate_cfg({"nla": "true"}) + assert cfg == {"nla": True} + frag = svc.compose_fragment("decky-test", service_cfg=cfg) + assert frag["environment"]["RDP_ENABLE_NLA"] == "true" + + +def test_rdp_nla_off_drops_env_var(): + svc = RDPService() + cfg = svc.validate_cfg({"nla": "false"}) + frag = svc.compose_fragment("decky-test", service_cfg=cfg) + assert "RDP_ENABLE_NLA" not in frag["environment"] + + +def test_mysql_schema_and_round_trip(): + assert {f.key for f in MySQLService.config_schema} == {"version"} + svc = MySQLService() + cfg = svc.validate_cfg({"version": "8.0.36"}) + frag = svc.compose_fragment("decky-test", service_cfg=cfg) + assert frag["environment"]["MYSQL_VERSION"] == "8.0.36" + + +def test_redis_schema_and_round_trip(): + assert {f.key for f in RedisService.config_schema} == {"version", "os_string"} + svc = RedisService() + cfg = svc.validate_cfg({"version": "7.2.4", "os_string": "Linux 5.15.0 x86_64"}) + frag = svc.compose_fragment("decky-test", service_cfg=cfg) + assert frag["environment"]["REDIS_VERSION"] == "7.2.4" + assert frag["environment"]["REDIS_OS"] == "Linux 5.15.0 x86_64" + + +def test_smtp_schema_and_round_trip(): + assert {f.key for f in SMTPService.config_schema} == {"banner", "mta"} + svc = SMTPService() + cfg = svc.validate_cfg({"banner": "mail.corp ESMTP", "mta": "exim"}) + frag = svc.compose_fragment("decky-test", service_cfg=cfg) + assert frag["environment"]["SMTP_BANNER"] == "mail.corp ESMTP" + assert frag["environment"]["SMTP_MTA"] == "exim" + + +def test_smtp_mta_enum_rejects_unknown(): + with pytest.raises(ConfigValidationError): + SMTPService().validate_cfg({"mta": "qmail"}) + + +def test_smtp_relay_schema_matches_smtp(): + assert ( + {f.key for f in SMTPRelayService.config_schema} + == {f.key for f in SMTPService.config_schema} + ) + svc = SMTPRelayService() + frag = svc.compose_fragment( + "decky-test", service_cfg=svc.validate_cfg({"banner": "x", "mta": "postfix"}) + ) + assert frag["environment"]["SMTP_OPEN_RELAY"] == "1" + assert frag["environment"]["SMTP_BANNER"] == "x" + + +# --- Textarea base64 transport ---------------------------------------------- + + +def _b64(s: str) -> str: + return "b64:" + base64.b64encode(s.encode("utf-8")).decode("ascii") + + +def test_textarea_b64_decoded(): + cfg = _Dummy().validate_cfg({"body": _b64("line1\nline2\nline3")}) + assert cfg == {"body": "line1\nline2\nline3"} + + +def test_textarea_b64_malformed_rejected(): + with pytest.raises(ConfigValidationError): + _Dummy().validate_cfg({"body": "b64:not-valid-base64!!"}) + + +def test_textarea_plain_passthrough_for_api_callers(): + # Direct API submitters don't base64-wrap; raw multi-line strings + # must pass through unchanged. + cfg = _Dummy().validate_cfg({"body": "raw\nstuff"}) + assert cfg == {"body": "raw\nstuff"} + + +def test_https_pem_round_trip_through_b64(): + pem = ( + "-----BEGIN CERTIFICATE-----\n" + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxxx\n" + "-----END CERTIFICATE-----\n" + ) + svc = HTTPSService() + cfg = svc.validate_cfg({"tls_cert": _b64(pem)}) + assert cfg["tls_cert"] == pem # newlines restored + frag = svc.compose_fragment("decky-test", service_cfg=cfg) + assert frag["environment"]["TLS_CERT"] == pem + + +def test_textarea_b64_handles_utf8(): + s = "héllo\nwörld\n☃" + cfg = _Dummy().validate_cfg({"body": _b64(s)}) + assert cfg == {"body": s}