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