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 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)

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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)},

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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(

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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 = {