1
Writing a Service Plugin
anti edited this page 2026-04-18 06:07:04 -04:00

Writing a Service Plugin

A service plugin is what makes a decky look like an SSH box, an SMB share, an MSSQL server, or whatever else. Plugins are auto-discovered from decnet/services/. You add a file, you get a service.

For runtime INI-driven custom services (no Python code at all), see Custom-Services — this page is for first-class plugins baked into the codebase.

The contract

Every plugin subclasses BaseService from decnet/services/base.py:

class BaseService(ABC):
    name: str                      # unique slug, e.g. "ssh"
    ports: list[int]               # in-container listen ports
    default_image: str             # Docker image tag, or "build"
    fleet_singleton: bool = False  # True = one instance fleet-wide

    @abstractmethod
    def compose_fragment(
        self,
        decky_name: str,
        log_target: str | None = None,
        service_cfg: dict | None = None,
    ) -> dict: ...

    def dockerfile_context(self) -> Path | None:
        return None

Rules the composer enforces so you do not have to:

  • Networking keys (networks, ipv4_address, mac_address) are injected by decnet/composer.py. Do not set them in compose_fragment.
  • If you return "build": {"context": ...}, make sure dockerfile_context() returns the same path so decnet deploy can pre-build the image.
  • log_target is "ip:port" when log forwarding is on, else None. Pass it into the container as an env var and let the in-container rsyslog bridge handle the rest.

Registration

There is no registration step. The registry in decnet/services/registry.py walks the decnet/services/ package at import time, imports every module, and picks up every BaseService subclass via __subclasses__(). Your plugin appears in decnet services and in all_services() the moment its file exists in the right directory.

To verify:

decnet services | grep <your-slug>

Templates

If your service needs a custom image (almost all do), drop the build context under templates/<slug>/:

templates/myservice/
  Dockerfile
  entrypoint.sh
  config/
    ...

Conventions the existing plugins follow:

  • Base the image on debian:bookworm-slim unless you have a reason to diverge. Heterogeneity is good — some services use Alpine, some use CentOS-derived images.
  • Bake an rsyslog or equivalent bridge into the image so the container emits RFC 5424 on stdout.
  • Never write DECNET, honeypot, or decoy strings into the image, banners, MOTDs, config files, or user-agents. See the stealth rule in Developer-Guide.

A minimal plugin

The smallest real plugin is about 50 lines. This one wraps a pre-built image and needs no Dockerfile:

# decnet/services/echoecho.py
from decnet.services.base import BaseService


class EchoEchoService(BaseService):
    """
    Tiny TCP echo service. Useful as a template and for testing the composer.

    service_cfg keys:
        greeting   First line sent on connect. Default: empty.
    """

    name = "echoecho"
    ports = [7]
    default_image = "ghcr.io/example/echoecho:1.0"
    fleet_singleton = False

    def compose_fragment(
        self,
        decky_name: str,
        log_target: str | None = None,
        service_cfg: dict | None = None,
    ) -> dict:
        cfg = service_cfg or {}
        env: dict = {
            "NODE_NAME": decky_name,
            "ECHO_GREETING": cfg.get("greeting", ""),
        }
        if log_target:
            env["SYSLOG_TARGET"] = log_target

        fragment: dict = {
            "image": self.default_image,
            "container_name": f"{decky_name}-echoecho",
            "restart": "unless-stopped",
            "environment": env,
        }
        return fragment

That is the whole plugin. Drop it in decnet/services/echoecho.py, run decnet services, and it shows up.

Adding a build context

If you need a custom image, reference templates/<slug>/ and implement dockerfile_context:

from pathlib import Path
from decnet.services.base import BaseService

TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "echoecho"


class EchoEchoService(BaseService):
    name = "echoecho"
    ports = [7]
    default_image = "build"

    def compose_fragment(self, decky_name, log_target=None, service_cfg=None):
        return {
            "build": {"context": str(TEMPLATES_DIR)},
            "container_name": f"{decky_name}-echoecho",
            "restart": "unless-stopped",
            "environment": {"NODE_NAME": decky_name},
        }

    def dockerfile_context(self) -> Path:
        return TEMPLATES_DIR

Look at decnet/services/ssh.py for a fully worked, stealth-aware example including a per-decky quarantine bind-mount.

Per-service persona config

service_cfg is the dict pulled from the matching [service.<slug>] section of the INI (see INI-Config-Format). Keep the keys documented in the class docstring — that docstring is the only user-facing reference.

Pytest coverage

Every plugin ships with tests. Drop them under tests/service_testing/test_<slug>.py. Cover at minimum:

  • Instantiation + registry lookup: all_services()["echoecho"] resolves.
  • compose_fragment returns the expected keys for a given decky_name and service_cfg.
  • Absence of DECNET / honeypot strings in rendered env, command, and template files — this is the stealth rule made executable.
  • If dockerfile_context() is set, that the path exists and contains a Dockerfile.

Run pytest tests/service_testing -q before committing. Features without tests do not land — see Developer-Guide.

Checklist

  • New file under decnet/services/<slug>.py, subclasses BaseService.
  • name, ports, default_image set. fleet_singleton if applicable.
  • compose_fragment returns networking-free compose dict.
  • If default_image == "build", dockerfile_context() returns the context path.
  • templates/<slug>/ exists with a Dockerfile (if building).
  • No DECNET / honeypot / decoy strings anywhere the attacker can see.
  • service_cfg keys documented in the class docstring.
  • Pytest coverage under tests/service_testing/.
  • decnet services lists the new slug.
  • Commit follows the style in Developer-Guide.