Add INI Config Format and Custom Services pages

2026-04-18 06:04:39 -04:00
parent 3804641d4e
commit 668ac52cad
2 changed files with 387 additions and 0 deletions

132
Custom-Services.md Normal file

@@ -0,0 +1,132 @@
# Custom Services (Bring-Your-Own)
DECNET ships 25+ first-class service plugins (see
[Services catalog](Services-Catalog)), but you can add your own **without
writing a plugin** by declaring a `[custom-<name>]` section in the INI.
The source of truth is `decnet/custom_service.py` (the `CustomService`
class) and the `[custom-*]` branch of `decnet/ini_loader.py`.
---
## How it works
At INI load time the parser scans every section starting with `custom-`:
```python
# decnet/ini_loader.py
if section.startswith("custom-"):
s = cp[section]
svc_name = section[len("custom-"):]
image = s.get("binary", "")
exec_cmd = s.get("exec", "")
ports_raw = s.get("ports", "")
ports = [int(p.strip()) for p in ports_raw.split(",") if p.strip().isdigit()]
cfg.custom_services.append(
CustomServiceSpec(name=svc_name, image=image, exec_cmd=exec_cmd, ports=ports)
)
```
Each resulting `CustomServiceSpec` is wrapped into a `CustomService`
(subclass of `BaseService`) and registered dynamically at deploy time
(`register_custom_service()` in the service registry). Once registered, the
custom slug behaves like any built-in service — you can list it in a decky's
`services=` line and reference it in the `--services` CLI flag.
`CustomService.compose_fragment()` produces a Docker Compose fragment of the
shape:
```yaml
image: <binary>
container_name: <decky>-<slug>
restart: unless-stopped
environment:
NODE_NAME: <decky>
LOG_TARGET: <optional, from --log-target>
command: [<tokens from exec=>] # only emitted if exec= is non-empty
```
Underscores in the custom name are converted to dashes in the container
suffix (`slug = name.replace("_", "-")`).
Note: `CustomService.dockerfile_context()` returns `None`, so DECNET will
**not** try to build the image. The image you name in `binary=` must be
pullable (public registry, or present in the local Docker daemon).
---
## Accepted keys
| Key | Required | Meaning |
|----------|----------|---------|
| `binary` | yes | Docker image reference, e.g. `myorg/weirdapp:1.2.3`. Becomes the container's `image:`. |
| `exec` | no | Command to run inside the container. Space-split into a list and emitted as Compose `command:`. If omitted, the image's default `CMD`/`ENTRYPOINT` is used. |
| `ports` | no | Comma-separated list of integer ports. Non-numeric tokens are silently dropped. Used by the deploy layer to expose/route the ports. |
There are no other keys. Anything else in the section is ignored by
`ini_loader.py`.
The custom service slug (the part after `custom-`) must not collide with a
built-in service name.
---
## Minimal working example
Ship a lightweight HTTP echo server as a decoy on port 9000:
```ini
[general]
net = 192.168.1.0/24
gw = 192.168.1.1
interface = eth0
# Define the custom service
[custom-echoweb]
binary = ealen/echo-server:0.9.2
exec = node index.js
ports = 80
# Use it on a decky, alongside a built-in service
[decky-app01]
ip = 192.168.1.130
services = ssh, echoweb
nmap_os = linux
[decky-app01.ssh]
ssh_version = OpenSSH_8.9p1 Ubuntu-3ubuntu0.6
kernel_version = 5.15.0-91-generic
users = root:toor
```
Deploy:
```bash
sudo decnet deploy --config echo.ini --interface eth0 \
--log-target 192.168.1.200:5140
```
At deploy time, `decky-app01` will get two containers: a standard `ssh`
decoy and a `decky-app01-echoweb` container running
`ealen/echo-server:0.9.2` with `command: ["node", "index.js"]`.
---
## Caveats
- No per-service persona subsection is honoured for custom services — the
`service_cfg` argument in `CustomService.compose_fragment()` is accepted
but ignored. If you need persona/override behaviour, write a real plugin.
- No Dockerfile build step. `binary=` is pulled as-is.
- If `exec=` is empty, no `command:` key is emitted — the image's default
entrypoint runs.
- `LOG_TARGET` is injected into the container env **only** if a CLI
`--log-target` was passed to `decnet deploy`; otherwise the custom service
is responsible for its own logging.
---
See also:
- [INI Config Format](INI-Config-Format)
- [Services catalog](Services-Catalog)
- [Service personas](Service-Personas)

255
INI-Config-Format.md Normal file

@@ -0,0 +1,255 @@
# INI Config Format
DECNET deployments are described by an INI file passed via `--config`. The file
is parsed by `decnet/ini_loader.py` (structural validation in
`decnet/models.py::validate_ini_string`) and converted into an `IniConfig` of
`DeckySpec` and `CustomServiceSpec` objects.
This page is an exhaustive reference. **Every key documented here is accepted
by the parser.** Keys not listed are ignored (or cause a validation error for
typed fields).
Related pages:
- [Services catalog](Services-Catalog)
- [Archetypes](Archetypes)
- [Service personas](Service-Personas)
- [Custom services](Custom-Services)
---
## Section types
A DECNET INI file contains four kinds of sections:
1. `[general]` — fleet-wide network settings (optional, but recommended).
2. `[<decky-name>]` — one decky (or a group of deckies if `amount=N`).
3. `[<decky-name>.<service>]` — persona/override config for one service on
one decky (or group).
4. `[custom-<name>]` — a bring-your-own service definition. See
[Custom services](Custom-Services).
Section names must match `^[A-Za-z0-9\-_.]+$` (enforced on the decky `name`).
### How the parser distinguishes a service subsection from a decky
A section with a dot in its name is treated as a **per-service subsection**
if and only if the last dotted segment is a **known service name** registered
in `decnet.services.registry.all_services()`. Otherwise the whole section
name is treated as a decky name.
Example: `[decky-01.ssh]` → subsection (because `ssh` is a registered service).
`[decky.webmail]` → decky section (because `webmail` is not a registered
service).
---
## `[general]` section
Fleet-wide settings. All keys optional.
| Key | Type | Meaning |
|-------------|--------|------------------------------------------------------|
| `net` | string | LAN CIDR for the decoy network, e.g. `192.168.1.0/24`. Maps to `IniConfig.subnet`. |
| `gw` | string | LAN gateway IP. Maps to `IniConfig.gateway`. |
| `interface` | string | Host interface to bind MACVLAN/IPVLAN to, e.g. `eth0`, `wlp6s0`. |
> **Note:** `log_target` is **not** an INI key. It is a CLI flag
> (`--log-target HOST:PORT`). You may leave a commented `#log_target=` reminder
> in your INI, but the parser will not read it.
Example:
```ini
[general]
net = 192.168.1.0/24
gw = 192.168.1.1
interface = eth0
```
---
## Decky sections
Each non-general, non-`custom-*` section that is not a service subsection
defines a decky (or a group). The section name becomes the decky's `name`.
| Key | Type | Default | Meaning |
|--------------------|----------|---------|---------|
| `ip` | string | auto | Static IP inside the subnet. **Forbidden when `amount>1`** — raises `ValueError`. |
| `services` | csv list | `None` | Comma-separated service slugs. If omitted, falls back to the `--randomize-services` pool or the archetype's service list. |
| `archetype` | string | `None` | Archetype slug (e.g. `linux-server`, `windows-workstation`). Archetypes pre-seed services, distro, and `nmap_os`. See [Archetypes](Archetypes). |
| `amount` | int | `1` | Number of deckies to spawn from this section. Must be `1..100`. When `>1`, the section acts as a template and deckies are named `<section>-01`, `<section>-02`, …. |
| `nmap_os` | string | `linux` | TCP/IP stack family for fingerprint spoofing. Accepted: `linux`, `windows`, `embedded`, `bsd`, `cisco`. Also accepted as `nmap-os` (alias). |
| `mutate_interval` | int | global | Auto-rotation interval in minutes (`>=1`). Also accepted as `mutate-interval` (alias). |
### `amount` expansion rules
```ini
[corp-workstations]
archetype = windows-workstation
amount = 5
```
Expands to `corp-workstations-01``corp-workstations-05`. Any
`[corp-workstations.<service>]` subsection propagates to **all five**
expanded deckies (see Resolution order below).
Setting `ip=` together with `amount>1` is rejected — one IP cannot be shared.
---
## Per-service subsections `[<decky>.<service>]`
Persona overrides for a single service on one decky (or a group). The last
dotted segment must be a registered service slug — otherwise the subsection
is ignored.
Every key inside such a subsection is passed through verbatim as a `dict`
into `DeckySpec.service_config[<service>]` and then consumed by the service
plugin's `compose_fragment(..., service_cfg=...)`. The available keys are
therefore **service-specific** — see [Service personas](Service-Personas) for
the full per-service key list.
Examples (excerpt from `development/test-full.ini`):
```ini
[decky-webmail.http]
server_header = Apache/2.4.54 (Debian)
response_code = 200
fake_app = wordpress
[decky-webmail.smtp]
smtp_banner = 220 mail.corp.local ESMTP Postfix (Debian/GNU)
smtp_mta = mail.corp.local
[decky-ldapdc.ssh]
ssh_version = OpenSSH_8.9p1 Ubuntu-3ubuntu0.6
kernel_version = 5.15.0-91-generic
users = root:toor,admin:admin123,svc_backup:backup2024
```
---
## Resolution order
When the runtime configuration for a decky's service is built, values are
layered in this order (later wins):
1. **Service defaults** — hard-coded inside the service plugin.
2. **Archetype** — if the decky's section had `archetype=…`, the archetype's
default services/distro/`nmap_os` apply.
3. **Per-decky keys**`ip`, `services`, `nmap_os`, `mutate_interval` from
the decky section override archetype defaults.
4. **Per-service subsection** — keys in `[<decky>.<service>]` override the
service's built-in defaults. For a group (`amount>N`), the subsection
`[<group>.<service>]` is copied into every expanded decky.
---
## Full realistic example
A 10-decky heterogeneous fleet, mixing an archetype pool with role-themed
boxes. This is a trimmed version of `development/test-full.ini`.
```ini
# full.ini — 10-decky heterogeneous fleet
#
# sudo decnet deploy --config full.ini --interface eth0 \
# --log-target 192.168.1.200:5140
[general]
net = 192.168.1.0/24
gw = 192.168.1.1
interface = eth0
# ── Archetype pool: 5 Windows workstations ───────────────────────────
[windows-workstation]
archetype = windows-workstation
amount = 5
# ── Web / Mail stack ─────────────────────────────────────────────────
[decky-webmail]
ip = 192.168.1.110
services = http, smtp, imap, pop3
nmap_os = linux
[decky-webmail.http]
server_header = Apache/2.4.54 (Debian)
fake_app = wordpress
[decky-webmail.smtp]
smtp_banner = 220 mail.corp.local ESMTP Postfix (Debian/GNU)
# ── Windows file server ──────────────────────────────────────────────
[decky-fileserv]
ip = 192.168.1.111
services = smb, ftp, tftp
nmap_os = windows
[decky-fileserv.smb]
workgroup = CORP
server_name = FILESERV01
os_version = Windows Server 2019
# ── Postgres / Mongo / Elastic ───────────────────────────────────────
[decky-dbsrv02]
ip = 192.168.1.113
services = postgres, mongodb, elasticsearch
nmap_os = linux
[decky-dbsrv02.postgres]
pg_version = 14.5
[decky-dbsrv02.elasticsearch]
es_version = 8.4.3
cluster_name = prod-search
# ── IoT / SCADA (embedded TCP stack) ─────────────────────────────────
[decky-iot]
ip = 192.168.1.117
services = mqtt, snmp, conpot
nmap_os = embedded
mutate_interval = 60
[decky-iot.snmp]
snmp_community = public
sys_descr = Linux router 5.4.0 #1 SMP x86_64
# ── Legacy admin box ─────────────────────────────────────────────────
[decky-legacy]
ip = 192.168.1.119
services = telnet, vnc, ssh
nmap_os = bsd
[decky-legacy.ssh]
ssh_version = OpenSSH_7.4p1 Debian-10+deb9u7
kernel_version = 4.9.0-19-amd64
users = root:root,admin:password,pi:raspberry
# ── Bring-your-own service (see Custom-Services) ─────────────────────
[custom-weirdapp]
binary = myorg/weirdapp:1.2.3
exec = /opt/weirdapp/run --listen 0.0.0.0:9000
ports = 9000
```
Deploy with:
```bash
sudo decnet deploy --config full.ini --interface eth0 \
--log-target 192.168.1.200:5140
```
---
## Validation and errors
- Empty INI (no sections): `"The provided INI content must contain at least
one section"`.
- Total payload > 512 KB: rejected.
- At least one decky section is mandatory (`IniConfig.at_least_one_decky`).
- `amount` must be a positive integer `<=100`.
- `ip=` with `amount>1` is rejected.
- `mutate_interval` must be an integer.
- `DeckySpec` and `CustomServiceSpec` both have `extra="forbid"` — unknown
*typed* fields on the parser's output raise Pydantic errors.