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:
@@ -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)
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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)},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }[] }>(
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user