From 64417d83536c5f4382aedb5c7b4edcbe9a1a4929 Mon Sep 17 00:00:00 2001
From: Bjoern Runden
Date: Sat, 3 Oct 2026 12:44:45 +0200
Subject: [PATCH] build - prozess optimiert mit push nach zot nginx-kill - >
maint. site...
---
.ai/sultan-system-architecture.md | 7 +-
CLAUDE.md | 6 +-
README.md | 8 +-
deploy/.env.example | 2 +-
deploy/compose/agent/.env.example | 5 +-
deploy/compose/agent/compose.yaml | 1 +
deploy/compose/server/.env.example | 25 +++++-
deploy/compose/server/compose.yaml | 40 ++++++----
deploy/compose/server/traefik-provider.yml | 12 +++
pkg/agent/agent.go | 22 +++++-
pkg/agent/agent_test.go | 43 ++++++++++-
pkg/agent/workloads.go | 78 +++++++++++++++++--
pkg/agent/workloads_test.go | 90 +++++++++++++++++++---
pkg/cli/cli.go | 13 +++-
pkg/cli/cli_test.go | 19 ++++-
pkg/docker/docker.go | 66 +++++++++++++++-
pkg/docker/docker_test.go | 49 +++++++++++-
pkg/server/server.go | 10 ++-
pkg/server/server_test.go | 12 +++
19 files changed, 447 insertions(+), 61 deletions(-)
create mode 100644 deploy/compose/server/traefik-provider.yml
diff --git a/.ai/sultan-system-architecture.md b/.ai/sultan-system-architecture.md
index 00c7bf2..da364db 100644
--- a/.ai/sultan-system-architecture.md
+++ b/.ai/sultan-system-architecture.md
@@ -500,6 +500,10 @@ Der Agent muss deshalb nach jedem (Wieder-)Verbinden seinen Zustand erneut sende
* Ohne Service-Labels entsteht ein Service mit dem Containernamen.
* Container, deren Port von außen nicht erreichbar ist (nicht veröffentlicht, nur auf `127.0.0.1`/`::1`, nur UDP), werden mit Warnung weggelassen.
* Es werden nur Container mit `traefik.enable=true` gemeldet.
+* **Gestoppt vs. entfernt:** Der Agent listet auch gestoppte Container (`/containers/json?all=1`).
+ * **Gestoppt** (`docker stop`, Absturz, `restarting`, `paused`, `created`): Der Container wird weiter gemeldet, seine Services stehen in `down_services` → **Wartungsseite (503)** statt 404. Die Ports stammen aus `HostConfig.PortBindings` (die Liste nennt bei gestoppten Containern keine Ports).
+ * **Entfernt** (`docker rm`, `docker compose down`): Die Route verschwindet – bewusstes Abschalten.
+ * Teilt ein gestoppter Container Router- oder Service-Namen mit einem laufenden (z. B. alte Kopie), entfällt der gestoppte.
* **Backend-Adresse**, in dieser Reihenfolge:
1. `--backend-host`
2. IP von `--backend-interface` (z. B. `wt0`)
@@ -787,8 +791,9 @@ services:
| 2026-10-02 | **Leases und Befehle umgesetzt:** Lease mit eigenem Signaturpräfix; ungeplant getrennter Knoten zeigt die Wartungsseite bis Lease-Ende; Befehle über eine DB-Warteschlange (CLI ist eigener Prozess); alle Server-Events signiert; `rotate_key` mit Besitznachweis und abbruchsicherer Wiederaufnahme. | Kein 404 bei kurzen Ausfällen; keine Fernsteuerung ohne Server-Signatur; Schlüsselwechsel ohne Aussperren. |
| 2026-10-02 | **Schritt 3 umgesetzt:** Checkmk-Ausgabe als reine Funktion (`pkg/checkmk`); Job bei jedem Refresh (Ablauf, Benachrichtigung, Aufräumen); Benachrichtigungen Slack-kompatibel + ntfy mit Wiederholung bis zur erfolgreichen Zustellung; Community läuft nicht über `valid_until` ab. | Testbar, ohne zusätzlichen Prozess; keine verlorenen Alarme. |
| 2026-10-02 | **Mehrere Server-Adressen:** `--public-url` mehrfach; die Einladung trägt nur die erste; die Liste kommt per signiertem `config`-Befehl bei jedem Verbinden; der Agent weicht der Reihe nach aus und merkt sich die funktionierende Adresse. | Kurze Einladung; Ausfall einer Adresse (DNS, Weiterleitung) ohne Eingriff überbrückt. |
-| 2026-10-02 | **Build & Auslieferung:** ein distroless-Image (nonroot) für Server und Agent, Multi-Arch amd64+arm64 per buildx mit Go-Cross-Compile (kein QEMU); Registry: eigener **Zot**-Server `pod.chmln.de`, Image `pod.chmln.de/sultan` (ohne Owner-Ebene, daher kein Rewrite-Proxy nötig); Zugangsdaten für den Push nur in `deploy/.env` (gitignored), ohne Passwort bricht `push-image.sh` ab. (Ursprünglich geplant: Gitea-Registry hinter Rewrite-Proxy – verworfen.) Binaries + `SHA256SUMS` statisch unter `sultan/releases//`, `releases/stable` als Zeiger. Agent-Container mit `network_mode: host`. Vorerst manuell über `deploy/*.sh`, später Gitea Actions bei Tags. | Kein Go-Stack beim Nutzer; ein Image für alle Architekturen; Agent meldet sonst die Container-IP als Backend; eigene Registry mit kurzen Image-Namen ohne Proxy-Tricks. |
+| 2026-10-02 | **Build & Auslieferung:** ein distroless-Image (nonroot) für Server und Agent, Multi-Arch amd64+arm64 per buildx mit Go-Cross-Compile (kein QEMU); Registry: eigener **Zot**-Server `zot.chmln.de`, Image `zot.chmln.de/sultan` (ohne Owner-Ebene, daher kein Rewrite-Proxy nötig); Zugangsdaten für den Push nur in `deploy/.env` (gitignored), ohne Passwort bricht `push-image.sh` ab. (Ursprünglich geplant: Gitea-Registry hinter Rewrite-Proxy – verworfen.) Binaries + `SHA256SUMS` statisch unter `sultan/releases//`, `releases/stable` als Zeiger. Agent-Container mit `network_mode: host`. Vorerst manuell über `deploy/*.sh`, später Gitea Actions bei Tags. | Kein Go-Stack beim Nutzer; ein Image für alle Architekturen; Agent meldet sonst die Container-IP als Backend; eigene Registry mit kurzen Image-Namen ohne Proxy-Tricks. |
| 2026-10-02 | **Kleinere Punkte:** Rate-Limit `/enroll` 10/min/IP vor der Signaturprüfung (`-1` = aus); `SULTAN_` für alle Flags, Flags gewinnen; „wieder online“ nur nach Offline-Alarm; Checkmk-Zeiten in Server-Zeitzone. Ein Agent, dessen Enrollment mit 429 scheitert, beendet sich; der Neustart erfolgt über systemd bzw. Docker `restart`. | Schutz vor Durchprobieren; Container-/systemd-freundliche Konfiguration; vollständige Alarmkette. |
+| 2026-10-03 | **Gestoppter Container = Wartungsseite, entfernter = Route weg.** Der Agent meldet gestoppte Container mit (Ports aus `PortBindings`) und deren Services als `down_services`; laufende Container gewinnen bei Namensgleichheit. Leerer `--public-url`-Wert wird ignoriert; `sultan version` zeigt die Zeitzone; `TZ` kommt über Compose (distroless enthält tzdata). Server-Compose mit `network_mode: host` neben Traefik. | Erster Test gegen echtes Traefik: `docker stop` lieferte 404 statt 503. Abschalten bleibt über Entfernen möglich. |
| geplant | Geordneter Zertifikatswechsel; optionale Root-CA. (Signierte Server-Events: umgesetzt.) | Zweite Sicherheitsschicht neben dem TLS-Pin; Betrieb ohne Aussperren. |
---
diff --git a/CLAUDE.md b/CLAUDE.md
index 978422c..477025f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -23,8 +23,8 @@ Go-Modul `sultan` (Go 1.26). `main.go` ist nur eine Hülle um `pkg/cli`; umgeset
- [pkg/server](pkg/server) – `sultan server`: `New` (Datenverzeichnis 0700, DB, Zertifikat, `master.key`), `Start`/`Run`/`Shutdown` (Hub zuerst schließen, sonst hängen SSE-Requests), Routen beider Ports (`handlers.go`), Knotenstatus über Hub-Callbacks, Refresh-Schleife, Audit-Log „Policy-Verstoß“ per `slog`. Tests laufen gegen echte Ports (`127.0.0.1:0`) mit Test-Agent (Pinning + Signatur).
- [pkg/cli](pkg/cli) – `Run(ctx, args, stdout, stderr) int`; Flags/Defaults von `sultan server`; `sultan token` in `token.go` (Befehlstabelle `tokenCmds`, `usageError` → Exit 2, `openDataDir` verweigert nicht initialisierte Verzeichnisse). Version per `-ldflags "-X sultan/pkg/cli.Version=…"`.
-- [pkg/docker](pkg/docker) – minimaler Docker-Engine-Client über Unix-Socket (`List`, `Events`), bewusst ohne SDK.
-- [pkg/agent](pkg/agent) – `sultan agent`: `Enroll` (node.key, Pinning über Einladungs-Fingerprint, node.json mit `server_fingerprint`; Adresse: `--server`/`SULTAN_SERVER` > URL in der Einladung > node.json, neue Adresse wird nach erfolgreichem Verbinden übernommen), `Run` (Stream mit `OnConnect` → Zustand neu senden, Docker-Watcher, Sammelintervall, kein Senden bei Docker-API-Fehler), `Workloads` (Container-Port → veröffentlichter Host-Port, Default-Service, unerreichbare Ports weglassen), `backendHost` (explizit → Interface → Route zum Server). Dazu `/etc/sultan.d`-YAML (`sultand.go`: `ParseServices`, `LoadServiceDir`, `FileWorkloads` → Traefik-Labels; Polling über `dirFingerprint`) und Healthchecks (`health.go`: `checkHealth`, `healthTracker` mit Fehlerschwelle) → `DownServices`. Tests laufen gegen echten `pkg/server` mit gefälschtem Docker-Socket.
+- [pkg/docker](pkg/docker) – minimaler Docker-Engine-Client über Unix-Socket (`List` mit `all=1`, `PortBindings` für gestoppte Container, `Events`), bewusst ohne SDK.
+- [pkg/agent](pkg/agent) – `sultan agent`: `Enroll` (node.key, Pinning über Einladungs-Fingerprint, node.json mit `server_fingerprint`; Adresse: `--server`/`SULTAN_SERVER` > URL in der Einladung > node.json, neue Adresse wird nach erfolgreichem Verbinden übernommen), `Run` (Stream mit `OnConnect` → Zustand neu senden, Docker-Watcher, Sammelintervall, kein Senden bei Docker-API-Fehler), `Workloads` (Container-Port → veröffentlichter Host-Port, Default-Service, unerreichbare Ports weglassen; gestoppte Container bleiben mit ihren Services in `down` → Wartungsseite, entfernte verschwinden, laufende gewinnen bei Namensgleichheit), `backendHost` (explizit → Interface → Route zum Server). Dazu `/etc/sultan.d`-YAML (`sultand.go`: `ParseServices`, `LoadServiceDir`, `FileWorkloads` → Traefik-Labels; Polling über `dirFingerprint`) und Healthchecks (`health.go`: `checkHealth`, `healthTracker` mit Fehlerschwelle) → `DownServices`. Tests laufen gegen echten `pkg/server` mit gefälschtem Docker-Socket.
- [pkg/lander](pkg/lander) – Interceptor-Seiten je `X-Sultan-Stage` (eingebettete `templates/*.html`, zweisprachig), Cookie `sultan_ack` bei watermark/firemark, `localTarget` erlaubt nur lokale Pfade (html/template filtert `meta refresh` **nicht**!), eigene Vorlagen aus `/landing` mit eingebettetem CSS/Bild, `Dump`. Läuft im Server auf `--lander-addr`; die Interceptor-URL der Policy folgt der gebundenen Adresse. Test-Server immer mit `LanderAddr: "127.0.0.1:0"` starten (sonst Portkonflikt auf 8081).
@@ -34,7 +34,7 @@ Go-Modul `sultan` (Go 1.26). `main.go` ist nur eine Hülle um `pkg/cli`; umgeset
**Mehrere Adressen (Schritt 4):** `Config.PublicURLs` (gemerkt als `settings.public_urls`, `server.LoadPublicURLs`), Versand per `config`-Befehl in `nodeOnline`; Agent: `Identity.ServerURLs`, `serverCandidates`/`nextServerURL` (Wechsel in `OnError` des Stream-Clients), `a.serverURL` nur über `currentServerURL()` lesen.
-- [deploy/](deploy) – Auslieferung: `Dockerfile` im Root (distroless, nonroot 65532, Multi-Arch, kein `ENTRYPOINT`, `WORKDIR /`), `build.sh` (Binaries amd64/arm64 + `SHA256SUMS` nach `dist//`), `push-image.sh` (liest `deploy/.env` mit `REGISTRY`/`REGISTRY_USER`/`REGISTRY_PASSWORD`, Vorlage `deploy/.env.example`; buildx → Zot-Registry `pod.chmln.de/sultan`; ohne Passwort Exit 1), `publish-static.sh` (rsync nach `sultan/releases//`), `compose/{server,agent}` (Agent mit `network_mode: host`). Version überall aus `git describe`; `-dirty` wird nicht veröffentlicht. `.dockerignore` hält `suldata/`, `.ai/`, `CLAUDE.md` aus dem Build-Kontext.
+- [deploy/](deploy) – Auslieferung: `Dockerfile` im Root (distroless, nonroot 65532, Multi-Arch, kein `ENTRYPOINT`, `WORKDIR /`), `build.sh` (Binaries amd64/arm64 + `SHA256SUMS` nach `dist//`), `push-image.sh` (liest `deploy/.env` mit `REGISTRY`/`REGISTRY_USER`/`REGISTRY_PASSWORD`, Vorlage `deploy/.env.example`; buildx → Zot-Registry `zot.chmln.de/sultan`; ohne Passwort Exit 1), `publish-static.sh` (rsync nach `sultan/releases//`), `compose/{server,agent}` (Agent mit `network_mode: host`). Version überall aus `git describe`; `-dirty` wird nicht veröffentlicht. `.dockerignore` hält `suldata/`, `.ai/`, `CLAUDE.md` aus dem Build-Kontext.
**CLI-Umgebung:** `pkg/cli/env.go` – `annotateEnv`/`applyEnv` machen jedes Flag als `SULTAN_` setzbar (nach `fs.Parse`, nur für nicht gesetzte Flags; Listen-Flags `stringList` kommagetrennt). Neue Flags bekommen das automatisch. Rate-Limit: `pkg/server/ratelimit.go` (`EnrollLimit`, `-1` = aus).
diff --git a/README.md b/README.md
index 13fc6ef..872714b 100644
--- a/README.md
+++ b/README.md
@@ -67,18 +67,18 @@ docker run --rm sultan:latest sultan version
Das Image ist distroless (nur das statische Binary, ~17 MB) und läuft als nonroot-User `65532`. Die Rolle wählt der Befehl: `sultan server …` bzw. `sultan agent …`.
-### 4. Image in die Registry schieben (Zot, `pod.chmln.de`)
+### 4. Image in die Registry schieben (Zot, `zot.chmln.de`)
```bash
cp deploy/.env.example deploy/.env # einmalig; gitignored – nie einchecken!
$EDITOR deploy/.env # REGISTRY_PASSWORD eintragen (Pflicht)
-deploy/push-image.sh # → pod.chmln.de/sultan:
+deploy/push-image.sh # → zot.chmln.de/sultan:
LATEST=1 deploy/push-image.sh # zusätzlich :latest (nur für Releases)
```
-Das Skript liest `deploy/.env` (Defaults: `REGISTRY=pod.chmln.de`, `REGISTRY_USER=sultan`, `IMAGE_NAME=sultan`; ohne `REGISTRY_PASSWORD` Abbruch), meldet sich per `docker login --password-stdin` an und baut per `docker buildx` gleichzeitig für `linux/amd64` und `linux/arm64` (Go kompiliert quer, kein QEMU nötig). Versionen mit `-dirty` werden verweigert. Weitere Stellschrauben: `PLATFORMS`, `ENV_FILE`.
+Das Skript liest `deploy/.env` (Defaults: `REGISTRY=zot.chmln.de`, `REGISTRY_USER=sultan`, `IMAGE_NAME=sultan`; ohne `REGISTRY_PASSWORD` Abbruch), meldet sich per `docker login --password-stdin` an und baut per `docker buildx` gleichzeitig für `linux/amd64` und `linux/arm64` (Go kompiliert quer, kein QEMU nötig). Versionen mit `-dirty` werden verweigert. Weitere Stellschrauben: `PLATFORMS`, `ENV_FILE`.
-Danach auf den Hosts: `SULTAN_IMAGE=pod.chmln.de/sultan:` in der Compose-`.env` und `docker compose pull && docker compose up -d`.
+Danach auf den Hosts: `SULTAN_IMAGE=zot.chmln.de/sultan:` in der Compose-`.env` und `docker compose pull && docker compose up -d`.
### 5. Binaries auf den statischen Webhost legen
diff --git a/deploy/.env.example b/deploy/.env.example
index 98cf050..ab6ee3a 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -1,7 +1,7 @@
# Zugangsdaten für deploy/push-image.sh – als deploy/.env kopieren (gitignored, nie einchecken!)
# Container-Registry (Zot)
-REGISTRY=pod.chmln.de
+REGISTRY=zot.chmln.de
REGISTRY_USER=sultan
# Pflicht – ohne Passwort bricht push-image.sh ab
REGISTRY_PASSWORD=
diff --git a/deploy/compose/agent/.env.example b/deploy/compose/agent/.env.example
index 8bd5107..ec58898 100644
--- a/deploy/compose/agent/.env.example
+++ b/deploy/compose/agent/.env.example
@@ -1,6 +1,7 @@
# Image: lokal gebaut (docker build -t sultan:latest .) oder aus der Registry
-SULTAN_IMAGE=sultan:latest
-# SULTAN_IMAGE=pod.chmln.de/sultan:v0.1.0
+# SULTAN_IMAGE=sultan:latest
+# SULTAN_IMAGE=zot.chmln.de/sultan:v0.1.0
+SULTAN_IMAGE=zot.chmln.de/sultan:latest
# Einladung vom Server: docker compose exec sultan-server sultan token invite --tenant=…
SULTAN_TOKEN=
diff --git a/deploy/compose/agent/compose.yaml b/deploy/compose/agent/compose.yaml
index 105faed..6f8df8a 100644
--- a/deploy/compose/agent/compose.yaml
+++ b/deploy/compose/agent/compose.yaml
@@ -20,6 +20,7 @@ services:
# Ausgabe von `sultan token invite`; nach dem ersten Start nicht mehr nötig,
# stehen lassen ist harmlos.
SULTAN_TOKEN: ${SULTAN_TOKEN:-}
+ TZ: ${TZ:-Europe/Berlin} # Zeitstempel im Log
# Lesezugriff auf den Docker-Socket für den nonroot-User (UID 65532).
group_add:
- "${DOCKER_GID:?DOCKER_GID setzen: getent group docker | cut -d: -f3}"
diff --git a/deploy/compose/server/.env.example b/deploy/compose/server/.env.example
index b82dde2..9be88ff 100644
--- a/deploy/compose/server/.env.example
+++ b/deploy/compose/server/.env.example
@@ -1,6 +1,25 @@
# Image: lokal gebaut (docker build -t sultan:latest .) oder aus der Registry
SULTAN_IMAGE=sultan:latest
-# SULTAN_IMAGE=pod.chmln.de/sultan:v0.1.0
+# SULTAN_IMAGE=git.everdc.eu/chmln-app-dev/sultan:v0.1.0
-# Adresse, unter der Agents den Server erreichen – steckt danach in jeder Einladung
-SULTAN_PUBLIC_URL=https://webproxy11.example.de:8443
+# Adresse(n), unter denen Agents den Server erreichen – die erste steckt in jeder
+# Einladung, alle bekommen Agents als Ausweichweg. Kommagetrennt, leer erlaubt.
+SULTAN_PUBLIC_URL=https://webproxy11.lan.r25dc.de:8443
+
+# Ports (host-Netz). Vorher prüfen, ob frei – Traefiks API/Dashboard nutzt oft 8080:
+# ss -ltnp | grep -E ':(8443|8080|8081)\b'
+# SULTAN_AGENT_ADDR=:8443
+# SULTAN_PROVIDER_ADDR=127.0.0.1:8080
+# SULTAN_LANDER_ADDR=127.0.0.1:8081
+
+# Texte/Links der Hinweis- und Einladungsseiten (optional)
+# SULTAN_HOSTER_NAME=everDC
+# SULTAN_HOSTER_URL=https://www.everdc.eu
+
+# Benachrichtigungen (optional, kommagetrennt mehrere möglich)
+# SULTAN_NOTIFY_SLACK=https://hooks.slack.com/services/…
+# SULTAN_NOTIFY_NTFY=https://ntfy.example.de/sultan
+# SULTAN_NOTIFY_NTFY_TOKEN=tk_…
+
+# Zeitzone für Wartungsseite und Checkmk (Default Europe/Berlin)
+# TZ=Europe/Berlin
diff --git a/deploy/compose/server/compose.yaml b/deploy/compose/server/compose.yaml
index 0d92bd2..d6c5a4d 100644
--- a/deploy/compose/server/compose.yaml
+++ b/deploy/compose/server/compose.yaml
@@ -3,25 +3,39 @@
# cp .env.example .env && $EDITOR .env
# install -d -m 0700 -o 65532 -g 65532 suldata # Datenverzeichnis für den nonroot-User
# docker compose up -d
+# docker compose exec sultan-server sultan version # Zeitzone prüfen
# docker compose exec sultan-server sultan token tenant add …
#
-# Traefik (Host-Netz oder Binary) pollt http://127.0.0.1:8080/v1/traefik-config.
+# network_mode: host wie Traefik:
+# - Ports frei wählbar (.env), ohne Mapping; die Adresse des Landers für die
+# Wartungsseiten stimmt damit automatisch.
+# - Rate-Limit und nodes.ip_address sehen die echte IP der Agents.
+# Traefik pollt http://${SULTAN_PROVIDER_ADDR}/v1/traefik-config (traefik.yml, siehe
+# traefik-provider.yml).
services:
sultan-server:
image: ${SULTAN_IMAGE:-sultan:latest}
container_name: sultan-server
restart: unless-stopped
- # Im Container auf allen Interfaces lauschen; nach außen nur 127.0.0.1 veröffentlichen.
- command:
- - "sultan"
- - "server"
- - "--data-dir=/suldata"
- - "--provider-addr=:8080"
- - "--lander-addr=:8081"
- - "--public-url=${SULTAN_PUBLIC_URL}"
- ports:
- - "8443:8443" # Agents (TLS 1.3)
- - "127.0.0.1:8080:8080" # Traefik-Provider & Checkmk – Klartext, nie öffentlich
- - "127.0.0.1:8081:8081" # Lander (Hinweis-/Wartungsseiten) für Traefik
+ network_mode: host
+ command: ["sultan", "server", "--data-dir=/suldata"]
+ environment:
+ # Agents (TLS 1.3) – in der Firewall für die Agent-Hosts öffnen.
+ SULTAN_AGENT_ADDR: ${SULTAN_AGENT_ADDR:-:8443}
+ # Traefik-Provider & Checkmk – Klartext, nur Loopback!
+ SULTAN_PROVIDER_ADDR: ${SULTAN_PROVIDER_ADDR:-127.0.0.1:8080}
+ # Lander (Hinweis-/Wartungsseiten) – nur Loopback, Traefik ruft ihn auf.
+ SULTAN_LANDER_ADDR: ${SULTAN_LANDER_ADDR:-127.0.0.1:8081}
+ # Leer = keine Adresse in Einladungen (Agent braucht dann --server).
+ # Mehrere Adressen kommagetrennt (öffentlich, NetBird …).
+ SULTAN_PUBLIC_URL: ${SULTAN_PUBLIC_URL:-}
+ SULTAN_HOSTER_NAME: ${SULTAN_HOSTER_NAME:-}
+ SULTAN_HOSTER_URL: ${SULTAN_HOSTER_URL:-}
+ SULTAN_NOTIFY_SLACK: ${SULTAN_NOTIFY_SLACK:-}
+ SULTAN_NOTIFY_NTFY: ${SULTAN_NOTIFY_NTFY:-}
+ SULTAN_NOTIFY_NTFY_TOKEN: ${SULTAN_NOTIFY_NTFY_TOKEN:-}
+ # Zeitzone für Wartungsseite und Checkmk (sonst UTC). Prüfen:
+ # docker compose exec sultan-server sultan version
+ TZ: ${TZ:-Europe/Berlin}
volumes:
- ./suldata:/suldata # sultan.db, server.key/.crt, master.key – sichern!
diff --git a/deploy/compose/server/traefik-provider.yml b/deploy/compose/server/traefik-provider.yml
new file mode 100644
index 0000000..c49de3b
--- /dev/null
+++ b/deploy/compose/server/traefik-provider.yml
@@ -0,0 +1,12 @@
+# Ausschnitt für Traefiks statische Konfiguration (traefik.yml) – in den
+# bestehenden Block "providers:" einfügen, NICHT als eigene Datei laden.
+# Der Docker- und File-Provider bleiben unverändert daneben aktiv.
+#
+# Routen aus Sultan heißen in Traefik ____@http.
+# Nach dem Einfügen Traefik neu starten (statische Konfiguration).
+
+providers:
+ http:
+ # Muss zu SULTAN_PROVIDER_ADDR in der .env von sultan-server passen.
+ endpoint: "http://127.0.0.1:8080/v1/traefik-config"
+ pollInterval: "3s"
diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go
index 7397f61..bd6471c 100644
--- a/pkg/agent/agent.go
+++ b/pkg/agent/agent.go
@@ -230,27 +230,41 @@ func (a *Agent) sendLoop(ctx context.Context, hc *http.Client, host string, trig
}
// collect ermittelt den aktuellen Zustand: Docker-Container und Dienste aus
-// ConfigDir samt Healthcheck-Ausfällen. Ohne Docker-Socket (Bare-Metal)
+// ConfigDir samt Ausfällen (gestoppte Container, Healthchecks). Ohne Docker-Socket (Bare-Metal)
// entfallen die Container; schlägt die Docker-API fehl, wird nichts
// gesendet (sonst würden alle Container-Routen entfernt).
func (a *Agent) collect(ctx context.Context, host string) (registry.NodeState, error) {
state := registry.NodeState{Workloads: []registry.Workload{}}
if a.dockerAvailable() {
- containers, err := (&docker.Client{Socket: a.cfg.DockerSocket}).List(ctx)
+ client := &docker.Client{Socket: a.cfg.DockerSocket}
+ containers, err := client.List(ctx)
if err != nil {
return state, err
}
- ws, warnings := Workloads(containers, host)
+ // Gestoppte Container nennen in der Liste keine Ports.
+ for i, c := range containers {
+ if c.State == "running" || !enabled(c.Labels) {
+ continue
+ }
+ ports, err := client.PortBindings(ctx, c.ID)
+ if err != nil {
+ a.log.Debug("Portzuordnung nicht lesbar", "container", c.Name(), "err", err)
+ continue // inzwischen entfernt o. Ä. → Workloads warnt
+ }
+ containers[i].Ports = ports
+ }
+ ws, down, warnings := Workloads(containers, host)
for _, w := range warnings {
a.log.Warn("Container nicht übernommen", "grund", w)
}
state.Workloads = append(state.Workloads, ws...)
+ state.DownServices = append(state.DownServices, down...)
}
a.mu.Lock()
specs := a.fileSpecs
a.mu.Unlock()
state.Workloads = append(state.Workloads, FileWorkloads(specs, host)...)
- state.DownServices = a.health.down()
+ state.DownServices = append(state.DownServices, a.health.down()...)
return state, nil
}
diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go
index 4a788bc..399b66d 100644
--- a/pkg/agent/agent_test.go
+++ b/pkg/agent/agent_test.go
@@ -5,6 +5,7 @@ import (
"crypto/ed25519"
"encoding/json"
"errors"
+ "fmt"
"io"
"log/slog"
"net"
@@ -152,10 +153,36 @@ func newFakeDocker(t *testing.T, containers ...docker.Container) *fakeDocker {
t.Fatal(err)
}
mux := http.NewServeMux()
+ // Wie Docker: gestoppte Container ohne Ports in der Liste; die Zuordnung
+ // gibt es nur über /containers/{id}/json.
mux.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
- json.NewEncoder(w).Encode(f.containers)
+ list := []docker.Container{}
+ for _, c := range f.containers {
+ if c.State != "running" {
+ c.Ports = nil
+ }
+ list = append(list, c)
+ }
+ json.NewEncoder(w).Encode(list)
+ })
+ mux.HandleFunc("/containers/{id}/json", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ for _, c := range f.containers {
+ if c.ID != r.PathValue("id") {
+ continue
+ }
+ bindings := map[string][]map[string]string{}
+ for _, p := range c.Ports {
+ key := fmt.Sprintf("%d/%s", p.PrivatePort, p.Type)
+ bindings[key] = append(bindings[key], map[string]string{"HostIp": p.IP, "HostPort": fmt.Sprint(p.PublicPort)})
+ }
+ json.NewEncoder(w).Encode(map[string]any{"Id": c.ID, "HostConfig": map[string]any{"PortBindings": bindings}})
+ return
+ }
+ http.Error(w, `{"message":"No such container"}`, http.StatusNotFound)
})
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
ch := make(chan docker.Event, 8)
@@ -256,8 +283,18 @@ func TestRun_EndToEnd(t *testing.T) {
t.Errorf("Knotenstatus %q", st)
}
- dock.set("die") // Container gestoppt
- eventually(t, "Route nach Containerstopp entfernt", func() bool { return s.traefikConfig(t) == "{}" })
+ // Gestoppt (docker stop, Absturz): Wartungsseite statt 404.
+ down := wordpress()
+ down.State = "exited"
+ dock.set("die", down)
+ eventually(t, "Wartungsseite nach Containerstopp", func() bool {
+ c := s.traefikConfig(t)
+ return strings.Contains(c, "kunde__node-1__wp") && strings.Contains(c, "sultan__interceptor") &&
+ strings.Contains(c, `"maintenance"`) && strings.Contains(c, "http://100.64.0.42:8081")
+ })
+
+ dock.set("destroy") // entfernt (docker compose down): Route weg
+ eventually(t, "Route nach Entfernen des Containers weg", func() bool { return s.traefikConfig(t) == "{}" })
dock.set("start", wordpress())
eventually(t, "Route nach Containerstart zurück", func() bool {
diff --git a/pkg/agent/workloads.go b/pkg/agent/workloads.go
index b1756c2..6859f2a 100644
--- a/pkg/agent/workloads.go
+++ b/pkg/agent/workloads.go
@@ -17,7 +17,7 @@ const servicesPrefix = "traefik.http.services."
var unsafeName = regexp.MustCompile(`[^A-Za-z0-9_-]`)
-// Workloads übersetzt laufende Container in Workloads für service_state.
+// Workloads übersetzt Container in Workloads für service_state.
//
// Berücksichtigt werden nur Container mit traefik.enable=true. Weil der
// Ingress auf einem anderen Host sitzt, wird loadbalancer.server.port (Port
@@ -27,9 +27,20 @@ var unsafeName = regexp.MustCompile(`[^A-Za-z0-9_-]`)
// Containernamen. Container, deren Ports von außen nicht erreichbar sind
// (nicht veröffentlicht, nur Loopback, nur UDP), werden mit Warnung
// weggelassen.
-func Workloads(containers []docker.Container, host string) ([]registry.Workload, []string) {
- var out []registry.Workload
- var warnings []string
+//
+// Gestoppte Container (State != "running", Ports aus PortBindings) bleiben
+// drin; ihre Services stehen in down, damit die Policy zur Wartungsseite
+// umleitet statt die Route zu entfernen. Überschneiden sich ihre Router-
+// oder Service-Namen mit einem laufenden Container, entfallen sie.
+func Workloads(containers []docker.Container, host string) (workloads []registry.Workload, down []string, warnings []string) {
+ type candidate struct {
+ workload registry.Workload
+ running bool
+ routers []string
+ services []string
+ }
+ var candidates []candidate
+ taken := map[string]bool{} // Namen laufender Container, "r:"/"s:" + klein
for _, c := range containers {
if !enabled(c.Labels) {
continue
@@ -39,10 +50,63 @@ func Workloads(containers []docker.Container, host string) ([]registry.Workload,
warnings = append(warnings, warning)
continue
}
- out = append(out, registry.Workload{Name: c.Name(), Host: host, Labels: labels})
+ cand := candidate{
+ workload: registry.Workload{Name: c.Name(), Host: host, Labels: labels},
+ running: c.State == "running",
+ routers: labelNames(labels, "traefik.http.routers."),
+ services: labelNames(labels, servicesPrefix),
+ }
+ if cand.running {
+ for _, r := range cand.routers {
+ taken["r:"+strings.ToLower(r)] = true
+ }
+ for _, s := range cand.services {
+ taken["s:"+strings.ToLower(s)] = true
+ }
+ }
+ candidates = append(candidates, cand)
}
- slices.SortFunc(out, func(a, b registry.Workload) int { return strings.Compare(a.Name, b.Name) })
- return out, warnings
+ collides := func(c candidate) bool {
+ for _, r := range c.routers {
+ if taken["r:"+strings.ToLower(r)] {
+ return true
+ }
+ }
+ for _, s := range c.services {
+ if taken["s:"+strings.ToLower(s)] {
+ return true
+ }
+ }
+ return false
+ }
+ for _, c := range candidates {
+ if !c.running {
+ if collides(c) {
+ continue
+ }
+ down = append(down, c.services...)
+ }
+ workloads = append(workloads, c.workload)
+ }
+ slices.SortFunc(workloads, func(a, b registry.Workload) int { return strings.Compare(a.Name, b.Name) })
+ slices.Sort(down)
+ return workloads, slices.Compact(down), warnings
+}
+
+// labelNames liefert die Namen (Schreibweise aus dem Label) unter prefix,
+// z. B. die Router aus traefik.http.routers..…
+func labelNames(labels map[string]string, prefix string) []string {
+ var out []string
+ for k := range labels {
+ if len(k) <= len(prefix) || !strings.EqualFold(k[:len(prefix)], prefix) {
+ continue
+ }
+ if name, _, ok := strings.Cut(k[len(prefix):], "."); ok && !slices.Contains(out, name) {
+ out = append(out, name)
+ }
+ }
+ slices.Sort(out)
+ return out
}
func enabled(labels map[string]string) bool {
diff --git a/pkg/agent/workloads_test.go b/pkg/agent/workloads_test.go
index 82c6d8b..01ecd64 100644
--- a/pkg/agent/workloads_test.go
+++ b/pkg/agent/workloads_test.go
@@ -27,7 +27,7 @@ func wpLabels() map[string]string {
func TestWorkloads_TranslatesPublishedPort(t *testing.T) {
in := ctr("wordpress", wpLabels(), pub(80, 8081), pub(443, 8443))
- got, warnings := Workloads([]docker.Container{in}, "100.64.0.42")
+ got, _, warnings := Workloads([]docker.Container{in}, "100.64.0.42")
if len(warnings) != 0 {
t.Errorf("Warnungen: %v", warnings)
}
@@ -52,7 +52,7 @@ func TestWorkloads_SkipsDisabled(t *testing.T) {
off["traefik.enable"] = "false"
none := wpLabels()
delete(none, "traefik.enable")
- got, warnings := Workloads([]docker.Container{
+ got, _, warnings := Workloads([]docker.Container{
ctr("aus", off, pub(80, 8081)),
ctr("ohne", none, pub(80, 8082)),
ctr("db", map[string]string{}, pub(5432, 5432)),
@@ -71,7 +71,7 @@ func TestWorkloads_UnreachablePorts(t *testing.T) {
"anderer Port": {pub(8080, 8081)},
}
for name, ports := range tests {
- got, warnings := Workloads([]docker.Container{ctr("wordpress", wpLabels(), ports...)}, "h")
+ got, _, warnings := Workloads([]docker.Container{ctr("wordpress", wpLabels(), ports...)}, "h")
if len(got) != 0 {
t.Errorf("%s: Workload trotz unerreichbarem Port: %+v", name, got)
}
@@ -82,7 +82,7 @@ func TestWorkloads_UnreachablePorts(t *testing.T) {
}
func TestWorkloads_IPv6AnyIsReachable(t *testing.T) {
- got, _ := Workloads([]docker.Container{ctr("wordpress", wpLabels(),
+ got, _, _ := Workloads([]docker.Container{ctr("wordpress", wpLabels(),
docker.Port{IP: "::", PrivatePort: 80, PublicPort: 8081, Type: "tcp"})}, "h")
if len(got) != 1 || got[0].Labels["traefik.http.services.wp.loadbalancer.server.port"] != "8081" {
t.Errorf("got = %+v", got)
@@ -93,7 +93,7 @@ func TestWorkloads_IPv6AnyIsReachable(t *testing.T) {
// mit dem Containernamen und dem einzigen veröffentlichten Port.
func TestWorkloads_DefaultService(t *testing.T) {
labels := map[string]string{"traefik.enable": "true", "traefik.http.routers.app.rule": "Host(`app.kunde.de`)"}
- got, warnings := Workloads([]docker.Container{ctr("my.app", labels, pub(3000, 13000))}, "h")
+ got, _, warnings := Workloads([]docker.Container{ctr("my.app", labels, pub(3000, 13000))}, "h")
if len(warnings) != 0 || len(got) != 1 {
t.Fatalf("got=%+v warnings=%v", got, warnings)
}
@@ -101,7 +101,7 @@ func TestWorkloads_DefaultService(t *testing.T) {
t.Errorf("Default-Service fehlt: %v", got[0].Labels)
}
- _, warnings = Workloads([]docker.Container{ctr("multi", labels, pub(3000, 13000), pub(3001, 13001))}, "h")
+ _, _, warnings = Workloads([]docker.Container{ctr("multi", labels, pub(3000, 13000), pub(3001, 13001))}, "h")
if len(warnings) != 1 || !strings.Contains(warnings[0], "multi") {
t.Errorf("mehrere Ports ohne Port-Label: Warnungen = %v", warnings)
}
@@ -113,7 +113,7 @@ func TestWorkloads_ServiceWithoutPortLabel(t *testing.T) {
"traefik.http.routers.wp.rule": "Host(`kunde.de`)",
"traefik.http.services.wp.loadbalancer.passhostheader": "true",
}
- got, warnings := Workloads([]docker.Container{ctr("wordpress", labels, pub(80, 8081))}, "h")
+ got, _, warnings := Workloads([]docker.Container{ctr("wordpress", labels, pub(80, 8081))}, "h")
if len(warnings) != 0 || len(got) != 1 || got[0].Labels["traefik.http.services.wp.loadbalancer.server.port"] != "8081" {
t.Errorf("got=%+v warnings=%v", got, warnings)
}
@@ -129,7 +129,7 @@ func TestWorkloads_MultipleServicesAndCaseInsensitiveKeys(t *testing.T) {
"traefik.http.services.web.loadbalancer.server.port": "80",
"Traefik.HTTP.Services.Api.LoadBalancer.Server.Port": "9000",
}
- got, warnings := Workloads([]docker.Container{ctr("app", labels, pub(80, 8080), pub(9000, 19000))}, "h")
+ got, _, warnings := Workloads([]docker.Container{ctr("app", labels, pub(80, 8080), pub(9000, 19000))}, "h")
if len(warnings) != 0 || len(got) != 1 {
t.Fatalf("got=%+v warnings=%v", got, warnings)
}
@@ -140,7 +140,7 @@ func TestWorkloads_MultipleServicesAndCaseInsensitiveKeys(t *testing.T) {
}
func TestWorkloads_SortedByName(t *testing.T) {
- got, _ := Workloads([]docker.Container{
+ got, _, _ := Workloads([]docker.Container{
ctr("b", wpLabels(), pub(80, 8082)),
ctr("a", wpLabels(), pub(80, 8081)),
}, "h")
@@ -148,3 +148,75 @@ func TestWorkloads_SortedByName(t *testing.T) {
t.Errorf("Reihenfolge: %+v", got)
}
}
+
+func stopped(c docker.Container, state string) docker.Container {
+ c.State = state
+ return c
+}
+
+// Gestoppt (nicht entfernt): Route bleibt, Services gelten als down → die
+// Policy leitet zur Wartungsseite statt 404. Die Ports kommen bei gestoppten
+// Containern aus PortBindings (der Agent setzt sie vorher in Ports ein).
+func TestWorkloads_StoppedContainerIsDown(t *testing.T) {
+ for _, state := range []string{"exited", "created", "restarting", "paused", "dead"} {
+ got, down, warnings := Workloads([]docker.Container{stopped(ctr("nginx", wpLabels(), pub(80, 9080)), state)}, "h")
+ if len(warnings) != 0 || len(got) != 1 {
+ t.Fatalf("%s: got=%+v warnings=%v", state, got, warnings)
+ }
+ if got[0].Labels["traefik.http.services.wp.loadbalancer.server.port"] != "9080" {
+ t.Errorf("%s: Labels = %v", state, got[0].Labels)
+ }
+ if !reflect.DeepEqual(down, []string{"wp"}) {
+ t.Errorf("%s: down = %v, erwartet [wp]", state, down)
+ }
+ }
+
+ // Default-Service und mehrere Services, alle down, sortiert.
+ labels := map[string]string{"traefik.enable": "true", "traefik.http.routers.app.rule": "Host(`app.kunde.de`)"}
+ _, down, _ := Workloads([]docker.Container{stopped(ctr("my.app", labels, pub(3000, 13000)), "exited")}, "h")
+ if !reflect.DeepEqual(down, []string{"my-app"}) {
+ t.Errorf("Default-Service: down = %v", down)
+ }
+ multi := map[string]string{
+ "traefik.enable": "true",
+ "traefik.http.routers.web.rule": "Host(`kunde.de`)",
+ "traefik.http.routers.web.service": "web",
+ "traefik.http.services.web.loadbalancer.server.port": "80",
+ "traefik.http.services.Api.loadbalancer.server.port": "9000",
+ }
+ _, down, _ = Workloads([]docker.Container{stopped(ctr("app", multi, pub(80, 8080), pub(9000, 19000)), "exited")}, "h")
+ if !reflect.DeepEqual(down, []string{"Api", "web"}) {
+ t.Errorf("mehrere Services: down = %v", down)
+ }
+
+ // Laufend: nichts down.
+ if _, down, _ := Workloads([]docker.Container{ctr("nginx", wpLabels(), pub(80, 9080))}, "h"); len(down) != 0 {
+ t.Errorf("laufend: down = %v", down)
+ }
+}
+
+// Ein gestoppter Container mit denselben Router- oder Service-Namen wie ein
+// laufender (z. B. alte Kopie) darf diesen nicht verdrängen.
+func TestWorkloads_RunningWinsOverStopped(t *testing.T) {
+ routerOnly := map[string]string{"traefik.enable": "true", "traefik.http.routers.WP.rule": "Host(`alt.kunde.de`)",
+ "traefik.http.services.other.loadbalancer.server.port": "80"}
+ serviceOnly := map[string]string{"traefik.enable": "true", "traefik.http.routers.x.rule": "Host(`x.kunde.de`)",
+ "traefik.http.routers.x.service": "wp", "traefik.http.services.wp.loadbalancer.server.port": "80"}
+ for name, labels := range map[string]map[string]string{"gleiche Labels": wpLabels(), "gleicher Router": routerOnly, "gleicher Service": serviceOnly} {
+ got, down, warnings := Workloads([]docker.Container{
+ stopped(ctr("a-alt", labels, pub(80, 9081)), "exited"),
+ ctr("b-neu", wpLabels(), pub(80, 9080)),
+ }, "h")
+ if len(got) != 1 || got[0].Name != "b-neu" || len(down) != 0 || len(warnings) != 0 {
+ t.Errorf("%s: got=%+v down=%v warnings=%v", name, got, down, warnings)
+ }
+ }
+
+ // Ohne Überschneidung bleiben beide.
+ other := map[string]string{"traefik.enable": "true", "traefik.http.routers.alt.rule": "Host(`alt.kunde.de`)",
+ "traefik.http.services.alt.loadbalancer.server.port": "80"}
+ got, down, _ := Workloads([]docker.Container{stopped(ctr("a-alt", other, pub(80, 9081)), "exited"), ctr("b-neu", wpLabels(), pub(80, 9080))}, "h")
+ if len(got) != 2 || !reflect.DeepEqual(down, []string{"alt"}) {
+ t.Errorf("ohne Überschneidung: got=%+v down=%v", got, down)
+ }
+}
diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go
index 6a2d3ff..8113a4b 100644
--- a/pkg/cli/cli.go
+++ b/pkg/cli/cli.go
@@ -50,7 +50,7 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fmt.Fprint(stdout, usage)
return 0
case "version":
- fmt.Fprintf(stdout, "sultan %s\n", Version)
+ fmt.Fprintf(stdout, "sultan %s\nZeitzone: %s\n", Version, time.Local)
return 0
case "server":
return runServer(ctx, args[1:], stdout, stderr)
@@ -127,5 +127,12 @@ func parseServerFlags(args []string, output io.Writer, getenv func(string) strin
// stringList ist ein mehrfach angebbares String-Flag.
type stringList []string
-func (l *stringList) String() string { return strings.Join(*l, ",") }
-func (l *stringList) Set(v string) error { *l = append(*l, v); return nil }
+func (l *stringList) String() string { return strings.Join(*l, ",") }
+
+// Set ignoriert leere Werte (z. B. eine leere Variable in Compose).
+func (l *stringList) Set(v string) error {
+ if v = strings.TrimSpace(v); v != "" {
+ *l = append(*l, v)
+ }
+ return nil
+}
diff --git a/pkg/cli/cli_test.go b/pkg/cli/cli_test.go
index ee522b1..5edb41d 100644
--- a/pkg/cli/cli_test.go
+++ b/pkg/cli/cli_test.go
@@ -33,9 +33,14 @@ func TestRun_Usage(t *testing.T) {
func TestRun_Version(t *testing.T) {
code, stdout, _ := run(context.Background(), "version")
- if code != 0 || strings.TrimSpace(stdout) != "sultan "+Version {
+ lines := strings.Split(strings.TrimSpace(stdout), "\n")
+ if code != 0 || lines[0] != "sultan "+Version {
t.Errorf("version: code=%d stdout=%q", code, stdout)
}
+ // Die aktive Zeitzone sichtbar machen (Distroless-Images ohne tzdata fallen still auf UTC).
+ if len(lines) < 2 || lines[1] != "Zeitzone: "+time.Local.String() {
+ t.Errorf("Zeitzone fehlt: %q", stdout)
+ }
}
func TestServerFlags(t *testing.T) {
@@ -219,3 +224,15 @@ func TestServerFlags_EnrollLimit(t *testing.T) {
t.Errorf("--enroll-limit=-1: %d", cfg.EnrollLimit)
}
}
+
+// Leere Werte (z. B. --public-url=${SULTAN_PUBLIC_URL} mit leerer Variable in
+// Compose) dürfen den Server nicht am Start hindern.
+func TestServerFlags_EmptyPublicURLIgnored(t *testing.T) {
+ cfg, err := parseServerFlags([]string{"--public-url=", "--public-url= ", "--notify-slack="}, &bytes.Buffer{}, noEnv)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(cfg.PublicURLs) != 0 || len(cfg.SlackWebhooks) != 0 {
+ t.Errorf("leere Werte übernommen: %q %q", cfg.PublicURLs, cfg.SlackWebhooks)
+ }
+}
diff --git a/pkg/docker/docker.go b/pkg/docker/docker.go
index 34d7bba..7ad3bcc 100644
--- a/pkg/docker/docker.go
+++ b/pkg/docker/docker.go
@@ -1,6 +1,7 @@
// Package docker ist ein minimaler Client für die Docker Engine API über
-// den Unix-Socket – genau das, was der Agent braucht: laufende Container
-// mit Labels und Ports auflisten und Container-Events verfolgen. Bewusst
+// den Unix-Socket – genau das, was der Agent braucht: Container mit Labels
+// und Ports auflisten, Portzuordnungen gestoppter Container lesen und
+// Container-Events verfolgen. Bewusst
// ohne Docker-SDK (schwere Abhängigkeit für zwei Endpunkte).
package docker
@@ -13,6 +14,9 @@ import (
"net"
"net/http"
"net/url"
+ "regexp"
+ "slices"
+ "strconv"
"strings"
)
@@ -85,9 +89,10 @@ func (c *Client) get(ctx context.Context, path string) (*http.Response, error) {
return resp, nil
}
-// List liefert die laufenden Container.
+// List liefert alle Container, auch gestoppte (State != "running"); bei
+// diesen ist Ports leer, siehe PortBindings.
func (c *Client) List(ctx context.Context) ([]Container, error) {
- resp, err := c.get(ctx, "/containers/json")
+ resp, err := c.get(ctx, "/containers/json?all=1")
if err != nil {
return nil, err
}
@@ -99,6 +104,59 @@ func (c *Client) List(ctx context.Context) ([]Container, error) {
return out, nil
}
+var validID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
+
+// PortBindings liefert die konfigurierten Portzuordnungen eines Containers
+// (HostConfig.PortBindings) – auch wenn er gestoppt ist. Ohne festen
+// Host-Port ist PublicPort 0. Sortiert nach "/".
+func (c *Client) PortBindings(ctx context.Context, id string) ([]Port, error) {
+ if !validID.MatchString(id) {
+ return nil, fmt.Errorf("docker: ungültige Container-ID %q", id)
+ }
+ resp, err := c.get(ctx, "/containers/"+id+"/json")
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ var info struct {
+ HostConfig struct {
+ PortBindings map[string][]struct {
+ HostIP string `json:"HostIp"`
+ HostPort string `json:"HostPort"`
+ } `json:"PortBindings"`
+ } `json:"HostConfig"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
+ return nil, fmt.Errorf("docker: Container %s: %w", id, err)
+ }
+ keys := make([]string, 0, len(info.HostConfig.PortBindings))
+ for k := range info.HostConfig.PortBindings {
+ keys = append(keys, k)
+ }
+ slices.Sort(keys)
+ var out []Port
+ for _, k := range keys {
+ port, proto, _ := strings.Cut(k, "/")
+ private, err := strconv.ParseUint(port, 10, 16)
+ if err != nil {
+ continue
+ }
+ if proto == "" {
+ proto = "tcp"
+ }
+ bindings := info.HostConfig.PortBindings[k]
+ if len(bindings) == 0 {
+ out = append(out, Port{PrivatePort: uint16(private), Type: proto})
+ continue
+ }
+ for _, b := range bindings {
+ public, _ := strconv.ParseUint(b.HostPort, 10, 16) // "" → 0
+ out = append(out, Port{IP: b.HostIP, PrivatePort: uint16(private), PublicPort: uint16(public), Type: proto})
+ }
+ }
+ return out, nil
+}
+
// Events verfolgt Container-Events, bis ctx endet oder der Stream abbricht.
// Der Event-Kanal wird danach geschlossen; errc liefert genau einen Wert
// (ctx.Err() bei Abbruch, sonst den Grund).
diff --git a/pkg/docker/docker_test.go b/pkg/docker/docker_test.go
index 76cffb8..c310f9a 100644
--- a/pkg/docker/docker_test.go
+++ b/pkg/docker/docker_test.go
@@ -33,7 +33,11 @@ func newFakeEngine(t *testing.T, handler func(w http.ResponseWriter, r *http.Req
t.Fatal(err)
}
mux := http.NewServeMux()
- mux.HandleFunc("/containers/json", handler)
+ mux.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
+ f.lastQ <- r.URL.RawQuery
+ handler(w, r)
+ })
+ mux.HandleFunc("/containers/{id}/json", handler)
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
f.lastQ <- r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
@@ -75,6 +79,10 @@ func TestList(t *testing.T) {
if err != nil {
t.Fatalf("List: %v", err)
}
+ // Auch gestoppte Container: Sie werden zur Wartungsseite statt zu 404.
+ if q := <-f.lastQ; q != "all=1" {
+ t.Errorf("Query = %q, erwartet all=1", q)
+ }
want := []Container{
{
ID: "abc", Names: []string{"/wordpress"}, State: "running",
@@ -91,6 +99,45 @@ func TestList(t *testing.T) {
}
}
+// Gestoppte Container haben in /containers/json keine Ports; die
+// Zuordnung steht in HostConfig.PortBindings.
+func TestPortBindings(t *testing.T) {
+ f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.PathValue("id") != "abc" {
+ http.Error(w, `{"message":"No such container"}`, http.StatusNotFound)
+ return
+ }
+ w.Write([]byte(`{"Id":"abc","State":{"Status":"exited"},"HostConfig":{"PortBindings":{
+ "80/tcp":[{"HostIp":"","HostPort":"9080"},{"HostIp":"::","HostPort":"9080"}],
+ "443/tcp":[{"HostIp":"127.0.0.1","HostPort":"9443"}],
+ "53/udp":[{"HostIp":"","HostPort":"5353"}],
+ "8080/tcp":[{"HostIp":"","HostPort":""}],
+ "9000/tcp":null}}}`))
+ })
+ c := &Client{Socket: f.path}
+ got, err := c.PortBindings(context.Background(), "abc")
+ if err != nil {
+ t.Fatalf("PortBindings: %v", err)
+ }
+ want := []Port{
+ {IP: "127.0.0.1", PrivatePort: 443, PublicPort: 9443, Type: "tcp"},
+ {IP: "", PrivatePort: 53, PublicPort: 5353, Type: "udp"},
+ {IP: "", PrivatePort: 80, PublicPort: 9080, Type: "tcp"},
+ {IP: "::", PrivatePort: 80, PublicPort: 9080, Type: "tcp"},
+ {IP: "", PrivatePort: 8080, Type: "tcp"}, // Host-Port erst beim Start vergeben
+ {IP: "", PrivatePort: 9000, Type: "tcp"},
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("PortBindings =\n%+v\nerwartet\n%+v", got, want)
+ }
+ if _, err := c.PortBindings(context.Background(), "weg"); err == nil || !strings.Contains(err.Error(), "404") {
+ t.Errorf("unbekannter Container: %v", err)
+ }
+ if _, err := c.PortBindings(context.Background(), "../x"); err == nil {
+ t.Error("ungültige ID akzeptiert")
+ }
+}
+
func TestList_Errors(t *testing.T) {
f := newFakeEngine(t, func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"message":"kaputt"}`, http.StatusInternalServerError)
diff --git a/pkg/server/server.go b/pkg/server/server.go
index 8f0e467..15dafbd 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -23,6 +23,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "strings"
"sync"
"time"
@@ -162,13 +163,18 @@ type Server struct {
// TLS-Zertifikat und Master-Key. Lauschen beginnt erst mit Start.
func New(ctx context.Context, cfg Config) (*Server, error) {
cfg = cfg.withDefaults()
- for i, raw := range cfg.PublicURLs {
+ var urls []string
+ for _, raw := range cfg.PublicURLs {
+ if strings.TrimSpace(raw) == "" {
+ continue // leere Einträge (z. B. leere Variable in Compose) ignorieren
+ }
u, err := crypto.ValidateServerURL(raw)
if err != nil {
return nil, fmt.Errorf("server: --public-url: %w", err)
}
- cfg.PublicURLs[i] = u
+ urls = append(urls, u)
}
+ cfg.PublicURLs = urls
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
return nil, fmt.Errorf("server: Datenverzeichnis: %w", err)
}
diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go
index 36faa8f..c480843 100644
--- a/pkg/server/server_test.go
+++ b/pkg/server/server_test.go
@@ -1200,3 +1200,15 @@ func TestConfigDefaults_EnrollLimitDisabled(t *testing.T) {
t.Errorf("-1 (unbegrenzt) überschrieben: %d", c.EnrollLimit)
}
}
+
+func TestPublicURLs_BlankEntriesIgnored(t *testing.T) {
+ dir := filepath.Join(t.TempDir(), "suldata")
+ s, err := New(ctx, Config{DataDir: dir, PublicURLs: []string{"", " ", "https://a.example:8443"}, Logger: slog.New(slog.DiscardHandler)})
+ if err != nil {
+ t.Fatalf("leere Einträge verhindern den Start: %v", err)
+ }
+ defer s.Shutdown(ctx)
+ if got, _ := s.PublicURLs(ctx); !reflect.DeepEqual(got, []string{"https://a.example:8443"}) {
+ t.Errorf("gespeichert: %v", got)
+ }
+}