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.
This commit is contained in:
2026-04-29 12:23:56 -04:00
parent d8fa7cc73d
commit 77ceb9d6f3
29 changed files with 312 additions and 17 deletions

View File

@@ -1,8 +1,16 @@
import base64
import binascii
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Literal 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"] FieldType = Literal["string", "password", "int", "bool", "textarea", "enum"]
@@ -105,8 +113,18 @@ class BaseService(ABC):
def _coerce(spec: ServiceConfigField, raw: Any) -> Any: def _coerce(spec: ServiceConfigField, raw: Any) -> Any:
t = spec.type t = spec.type
if t in ("string", "password", "textarea"): if t in ("string", "password"):
return str(raw) 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": if t == "int":
try: try:
return int(raw) return int(raw)

View File

@@ -12,6 +12,7 @@ class ConpotService(BaseService):
name = "conpot" name = "conpot"
ports = [502, 161, 80] ports = [502, 161, 80]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
env = { env = {

View File

@@ -8,6 +8,7 @@ class DockerAPIService(BaseService):
name = "docker_api" name = "docker_api"
ports = [2375, 2376] ports = [2375, 2376]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -9,6 +9,7 @@ class ElasticsearchService(BaseService):
name = "elasticsearch" name = "elasticsearch"
ports = [9200] ports = [9200]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -8,6 +8,7 @@ class FTPService(BaseService):
name = "ftp" name = "ftp"
ports = [21] ports = [21]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -8,6 +8,7 @@ class IMAPService(BaseService):
name = "imap" name = "imap"
ports = [143, 993] ports = [143, 993]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -8,6 +8,7 @@ class KubernetesAPIService(BaseService):
name = "k8s" name = "k8s"
ports = [6443, 8080] ports = [6443, 8080]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -8,6 +8,7 @@ class LDAPService(BaseService):
name = "ldap" name = "ldap"
ports = [389, 636] ports = [389, 636]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -15,6 +15,7 @@ class LLMNRService(BaseService):
name = "llmnr" name = "llmnr"
ports = [5355, 5353] ports = [5355, 5353]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -8,6 +8,7 @@ class MongoDBService(BaseService):
name = "mongodb" name = "mongodb"
ports = [27017] ports = [27017]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -8,6 +8,7 @@ class MQTTService(BaseService):
name = "mqtt" name = "mqtt"
ports = [1883] ports = [1883]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -8,6 +8,7 @@ class MSSQLService(BaseService):
name = "mssql" name = "mssql"
ports = [1433] ports = [1433]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -1,5 +1,5 @@
from pathlib import Path 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" TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "mysql"
@@ -9,6 +9,16 @@ class MySQLService(BaseService):
ports = [3306] ports = [3306]
default_image = "build" 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( def compose_fragment(
self, self,
decky_name: str, decky_name: str,

View File

@@ -8,6 +8,7 @@ class POP3Service(BaseService):
name = "pop3" name = "pop3"
ports = [110, 995] ports = [110, 995]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -8,6 +8,7 @@ class PostgresService(BaseService):
name = "postgres" name = "postgres"
ports = [5432] ports = [5432]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -1,5 +1,5 @@
from pathlib import Path 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" TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "rdp"
@@ -9,6 +9,19 @@ class RDPService(BaseService):
ports = [3389] ports = [3389]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {
"build": {"context": str(TEMPLATES_DIR)}, "build": {"context": str(TEMPLATES_DIR)},

View File

@@ -1,5 +1,5 @@
from pathlib import Path 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" TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "redis"
@@ -9,6 +9,23 @@ class RedisService(BaseService):
ports = [6379] ports = [6379]
default_image = "build" 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( def compose_fragment(
self, self,
decky_name: str, decky_name: str,

View File

@@ -8,6 +8,7 @@ class SIPService(BaseService):
name = "sip" name = "sip"
ports = [5060] ports = [5060]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -8,6 +8,7 @@ class SMBService(BaseService):
name = "smb" name = "smb"
ports = [445, 139] ports = [445, 139]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -1,7 +1,7 @@
import os import os
from pathlib import Path 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" TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "smtp"
ARTIFACTS_ROOT = os.environ.get("DECNET_ARTIFACTS_ROOT", "/var/lib/decnet/artifacts") ARTIFACTS_ROOT = os.environ.get("DECNET_ARTIFACTS_ROOT", "/var/lib/decnet/artifacts")
@@ -16,6 +16,24 @@ class SMTPService(BaseService):
ports = [25, 587] ports = [25, 587]
default_image = "build" 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( def compose_fragment(
self, self,
decky_name: str, decky_name: str,

View File

@@ -1,7 +1,7 @@
import os import os
from pathlib import Path 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 # Reuses the same template as the smtp service — only difference is
# SMTP_OPEN_RELAY=1 in the environment, which enables the open relay persona. # SMTP_OPEN_RELAY=1 in the environment, which enables the open relay persona.
@@ -18,6 +18,24 @@ class SMTPRelayService(BaseService):
ports = [25, 587] ports = [25, 587]
default_image = "build" 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( def compose_fragment(
self, self,
decky_name: str, decky_name: str,

View File

@@ -16,6 +16,7 @@ class SnifferService(BaseService):
name = "sniffer" name = "sniffer"
ports: list[int] = [] ports: list[int] = []
default_image = "build" default_image = "build"
# config_schema: no user-tunable fields yet — TODO add when compose_fragment grows cfg reads
fleet_singleton = True fleet_singleton = True
def compose_fragment( def compose_fragment(

View File

@@ -8,6 +8,7 @@ class SNMPService(BaseService):
name = "snmp" name = "snmp"
ports = [161] ports = [161]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -1,7 +1,7 @@
import os import os
from pathlib import Path 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" TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "telnet"
ARTIFACTS_ROOT = os.environ.get("DECNET_ARTIFACTS_ROOT", "/var/lib/decnet/artifacts") ARTIFACTS_ROOT = os.environ.get("DECNET_ARTIFACTS_ROOT", "/var/lib/decnet/artifacts")
@@ -24,6 +24,27 @@ class TelnetService(BaseService):
ports = [23] ports = [23]
default_image = "build" 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( def compose_fragment(
self, self,
decky_name: str, decky_name: str,

View File

@@ -8,6 +8,7 @@ class TFTPService(BaseService):
name = "tftp" name = "tftp"
ports = [69] ports = [69]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -8,6 +8,7 @@ class VNCService(BaseService):
name = "vnc" name = "vnc"
ports = [5900] ports = [5900]
default_image = "build" 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: def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict:
fragment: dict = { fragment: dict = {

View File

@@ -2,12 +2,28 @@
set -e set -e
TLS_DIR="/opt/tls" TLS_DIR="/opt/tls"
CERT="${TLS_CERT:-$TLS_DIR/cert.pem}" mkdir -p "$TLS_DIR"
KEY="${TLS_KEY:-$TLS_DIR/key.pem}"
# 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 # Generate a self-signed certificate if none exists
if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then
mkdir -p "$TLS_DIR"
CN="${TLS_CN:-${NODE_NAME:-localhost}}" CN="${TLS_CN:-${NODE_NAME:-localhost}}"
openssl req -x509 -newkey rsa:2048 -nodes \ openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$KEY" -out "$CERT" \ -keyout "$KEY" -out "$CERT" \
@@ -15,4 +31,8 @@ if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then
2>/dev/null 2>/dev/null
fi fi
# server.py reads TLS_CERT/TLS_KEY as filesystem paths.
export TLS_CERT="$CERT"
export TLS_KEY="$KEY"
exec python3 /opt/server.py exec python3 /opt/server.py

View File

@@ -437,11 +437,20 @@ const PLACEHOLDER_LINES = (
`[OK] ${count} deckies online — fleet size now ${fleetSize + count}`, `[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 = ( const _buildIni = (
prefix: string, count: number, fleetSize: number, prefix: string, count: number, fleetSize: number,
mode: PickMode, archetype: Archetype | null, services: string[], mode: PickMode, archetype: Archetype | null, services: string[],
mutate: boolean, mutateEvery: number, mutate: boolean, mutateEvery: number,
serviceConfigs: Record<string, Record<string, unknown>>, serviceConfigs: Record<string, Record<string, unknown>>,
serviceSchemas: Record<string, SvcFieldDTO[]>,
): string => { ): string => {
const lines: string[] = []; const lines: string[] = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@@ -461,14 +470,19 @@ const _buildIni = (
for (const svc of services) { for (const svc of services) {
const cfg = serviceConfigs[svc]; const cfg = serviceConfigs[svc];
if (!cfg || Object.keys(cfg).length === 0) continue; if (!cfg || Object.keys(cfg).length === 0) continue;
const fieldTypes: Record<string, SvcFieldDTO['type']> = {};
for (const f of serviceSchemas[svc] ?? []) fieldTypes[f.key] = f.type;
lines.push(`[${prefix}.${svc}]`); lines.push(`[${prefix}.${svc}]`);
for (const [k, v] of Object.entries(cfg)) { for (const [k, v] of Object.entries(cfg)) {
// INI values can't carry literal newlines; collapse multi-line // textarea values may contain newlines that ConfigParser can't carry
// values (PEM textareas etc.) to \n escapes. Single-line values // on a single line; wrap them in `b64:` so validate_cfg decodes back
// are unaffected; multi-line consumers must re-expand. // to the original UTF-8 string. Other types are emitted raw.
const serialised = typeof v === 'string' let serialised: string;
? v.replace(/\r?\n/g, '\\n') if (fieldTypes[k] === 'textarea' && typeof v === 'string') {
: String(v); serialised = `b64:${_b64encodeUtf8(v)}`;
} else {
serialised = typeof v === 'string' ? v : String(v);
}
lines.push(`${k}=${serialised}`); lines.push(`${k}=${serialised}`);
} }
lines.push(''); lines.push('');
@@ -596,7 +610,7 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
: selectedServices; : selectedServices;
const ini = _buildIni( const ini = _buildIni(
prefix, count, fleetSize, pickMode, archetype, servicesForIni, prefix, count, fleetSize, pickMode, archetype, servicesForIni,
mutate, mutateEvery, rolled, mutate, mutateEvery, rolled, serviceSchemas,
); );
try { try {
const res = await api.post<{ failures?: { name: string; reason: string }[] }>( const res = await api.post<{ failures?: { name: string; reason: string }[] }>(

View File

@@ -1,5 +1,7 @@
"""Schema-driven service config: descriptors, validation, compose round-trip.""" """Schema-driven service config: descriptors, validation, compose round-trip."""
import base64
import pytest import pytest
from decnet.services.base import ( from decnet.services.base import (
@@ -9,7 +11,13 @@ from decnet.services.base import (
) )
from decnet.services.http import HTTPService from decnet.services.http import HTTPService
from decnet.services.https import HTTPSService 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.ssh import SSHService
from decnet.services.telnet import TelnetService
class _Dummy(BaseService): class _Dummy(BaseService):
@@ -130,3 +138,122 @@ def test_https_schema_includes_tls_fields():
assert {"tls_cn", "tls_cert", "tls_key"} <= keys assert {"tls_cn", "tls_cert", "tls_key"} <= keys
secrets = {f.key for f in HTTPSService.config_schema if f.secret} secrets = {f.key for f in HTTPSService.config_schema if f.secret}
assert {"tls_cert", "tls_key"} <= secrets 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}