Compare commits

...
2 Commits
Author SHA1 Message Date
admin c8728746f1 refactor: reduce deployment to one compose file
Container-Image veröffentlichen / container (push) Has been cancelled
2026-07-12 22:17:04 +02:00
admin 888d9a54a7 docs: use registry instead of production repository 2026-07-12 21:52:07 +02:00
12 changed files with 143 additions and 327 deletions

No files matched your search

-10
View File
@@ -1,10 +0,0 @@
# For LAN use, set this to the host's specific private address, for example 192.168.178.10.
# Keep 127.0.0.1 when a reverse proxy terminates HTTPS locally.
BIND_ADDRESS=127.0.0.1
WEB_PORT=8080
# lan is only appropriate for an isolated, trusted LAN. Set https behind a TLS reverse proxy.
APP_MODE=lan
# Create this file once with: openssl rand -base64 32 > secrets/dyndns_master_key
MASTER_KEY_FILE=./secrets/dyndns_master_key
-11
View File
@@ -1,11 +0,0 @@
# Unveränderlichen Release-Tag aus der Gitea Container Registry verwenden.
DYNDNS_IMAGE=git.j-mahler.de/admin/domain-control-webinterface-prod
IMAGE_TAG=v1.0.0
# Hinter einem HTTPS-Reverse-Proxy ausschließlich an localhost binden.
BIND_ADDRESS=127.0.0.1
WEB_PORT=8080
APP_MODE=https
# Datei einmalig auf dem Produktionsserver erzeugen und dauerhaft sichern.
MASTER_KEY_FILE=./secrets/dyndns_master_key
+1 -1
View File
@@ -15,7 +15,7 @@ FROM alpine:3.23.2 AS runtime
RUN apk add --no-cache ca-certificates && \
addgroup -S -g 10001 dyndns && \
adduser -S -D -H -u 10001 -G dyndns dyndns && \
mkdir -p /data && chown dyndns:dyndns /data
mkdir -p /data /secrets && chown dyndns:dyndns /data /secrets
COPY --from=build /out/dyndns-web /usr/local/bin/dyndns-web
-170
View File
@@ -1,170 +0,0 @@
# Produktivbetrieb mit Gitea und Docker Compose
Dieses Repository veröffentlicht versionierte Container-Images unter:
```text
git.j-mahler.de/admin/domain-control-webinterface-prod:vX.Y.Z
```
Das Image enthält ausschließlich die Anwendung. SQLite-Datenbank und Master-Key liegen dauerhaft auf dem Produktionsserver und bleiben bei Image-Updates erhalten.
## 1. Gitea für Releases vorbereiten
In den Repository-Einstellungen unter **Actions → Secrets** diese zwei Secrets anlegen:
- `REGISTRY_USERNAME`: Gitea-Benutzer oder eigener Release-Bot
- `REGISTRY_TOKEN`: persönlicher Gitea-Token mit Schreibrecht für Container-Pakete
Auf der Gitea-Instanz muss ein Actions-Runner mit dem Label `ubuntu-latest` registriert sein. Jeder Git-Tag, der mit `v` beginnt, startet anschließend `.gitea/workflows/release.yml` und baut Images für AMD64 und ARM64.
Erstes Release veröffentlichen:
```bash
git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0
```
Nach erfolgreicher Action stehen `v1.0.0` und der bewegliche Tag `stable` in der Gitea Container Registry. Auf Produktion immer den unveränderlichen Versionstag verwenden.
### Image alternativ manuell veröffentlichen
Falls Gitea Actions noch nicht eingerichtet ist:
```bash
echo "$GITEA_TOKEN" | docker login git.j-mahler.de --username DEIN_BENUTZER --password-stdin
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t git.j-mahler.de/admin/domain-control-webinterface-prod:v1.0.0 \
-t git.j-mahler.de/admin/domain-control-webinterface-prod:stable \
--push .
```
## 2. Produktionsserver vorbereiten
Voraussetzungen:
- Linux-Server mit Docker Engine und Docker-Compose-Plugin
- HTTPS-Reverse-Proxy wie Caddy, nginx oder Traefik
- DNS-Name, der auf den Server zeigt
Repository installieren:
```bash
sudo mkdir -p /opt/domain-control
sudo chown "$USER":"$USER" /opt/domain-control
git clone https://git.j-mahler.de/admin/domain-control-webinterface-prod.git /opt/domain-control
cd /opt/domain-control
cp .env.production.example .env.production
mkdir -p secrets backups
chmod 700 secrets backups
openssl rand -base64 32 > secrets/dyndns_master_key
chmod 644 secrets/dyndns_master_key
```
Der Master-Key wird vom unprivilegierten Benutzer im Container gelesen. Deshalb ist die Datei lesbar, während das übergeordnete Verzeichnis auf dem Host nur für seinen Besitzer zugänglich bleibt. Den Schlüssel niemals nachträglich ersetzen: Ohne ihn lassen sich gespeicherte Hetzner-Tokens nicht mehr entschlüsseln.
Bei einer privaten Registry auf dem Produktionsserver einmalig mit einem Token anmelden, der nur Leserechte auf Container-Pakete besitzt:
```bash
echo "$GITEA_PULL_TOKEN" | docker login git.j-mahler.de --username deploy-bot --password-stdin
```
In `.env.production` mindestens `IMAGE_TAG` kontrollieren:
```dotenv
DYNDNS_IMAGE=git.j-mahler.de/admin/domain-control-webinterface-prod
IMAGE_TAG=v1.0.0
BIND_ADDRESS=127.0.0.1
WEB_PORT=8080
APP_MODE=https
MASTER_KEY_FILE=./secrets/dyndns_master_key
```
Anwendung laden und starten:
```bash
docker compose --env-file .env.production -f compose.production.yml pull
docker compose --env-file .env.production -f compose.production.yml up -d
docker compose --env-file .env.production -f compose.production.yml ps
curl --fail http://127.0.0.1:8080/healthz
```
## 3. HTTPS-Reverse-Proxy
Port 8080 bleibt mit `BIND_ADDRESS=127.0.0.1` ausschließlich lokal erreichbar. Ein minimales Caddy-Beispiel:
```caddyfile
dns.example.de {
reverse_proxy 127.0.0.1:8080
}
```
Danach `https://dns.example.de` öffnen und den ersten Administrator anlegen. `APP_MODE=https` sorgt dabei für HTTPS-gebundene Session-Cookies.
Nur in einem isolierten, vertrauenswürdigen LAN darf stattdessen eine private Server-IP und `APP_MODE=lan` verwendet werden. Den Dienst nicht unverschlüsselt über `0.0.0.0` ins Internet stellen.
## 4. Backup vor jedem Update
Für eine konsistente SQLite-Kopie wird der Container kurz gestoppt:
```bash
cd /opt/domain-control
docker compose --env-file .env.production -f compose.production.yml stop dyndns
docker run --rm \
-v domain-control-data:/data:ro \
-v "$PWD/backups:/backup" \
alpine:3.23.2 \
cp /data/dyndns.db /backup/dyndns-before-update.db
docker compose --env-file .env.production -f compose.production.yml start dyndns
```
Zusätzlich `secrets/dyndns_master_key` verschlüsselt und außerhalb dieses Servers sichern. Eine Datenbanksicherung ohne den dazugehörigen Master-Key ist für gespeicherte API-Tokens unbrauchbar.
## 5. Update
Nach Veröffentlichung beispielsweise von `v1.1.0`:
1. Backup wie oben erstellen.
2. In `.env.production` setzen:
```dotenv
IMAGE_TAG=v1.1.0
```
3. Neues Image laden und Container ersetzen:
```bash
docker compose --env-file .env.production -f compose.production.yml pull
docker compose --env-file .env.production -f compose.production.yml up -d
docker compose --env-file .env.production -f compose.production.yml ps
docker compose --env-file .env.production -f compose.production.yml logs --tail=100
curl --fail http://127.0.0.1:8080/healthz
```
Das benannte Volume `domain-control-data` wird dabei nicht ersetzt.
## 6. Rollback
Ohne Datenbankmigration reicht es, in `.env.production` den vorherigen `IMAGE_TAG` einzutragen und erneut `pull` sowie `up -d` auszuführen.
Wenn die neue Version eine Datenbankmigration ausgeführt hat, gemeinsam auf das vorherige Image und die unmittelbar davor erstellte Datenbanksicherung zurückgehen. Automatische, unbeaufsichtigte Updates sind deshalb nicht empfohlen.
## 7. Nützliche Betriebsbefehle
```bash
# Status
docker compose --env-file .env.production -f compose.production.yml ps
# Logs
docker compose --env-file .env.production -f compose.production.yml logs -f --tail=100
# Neustart
docker compose --env-file .env.production -f compose.production.yml restart dyndns
# Bereits heruntergeladene, ungenutzte Images aufräumen
docker image prune
```
+51 -59
View File
@@ -1,76 +1,68 @@
# Hetzner DynDNS Dashboard
# Hetzner DynDNS
Ein schlankes, selbst gehostetes Dashboard für mehrere Hetzner-DNS-Zonen und DynDNS-Ziele. Der Container erkennt seine öffentliche IPv4/IPv6 in einem einstellbaren Rhythmus und aktualisiert ausschließlich aktivierte A-/AAAA-Records über die Hetzner Cloud DNS API.
Weboberfläche für mehrere Hetzner-DNS-Zonen, Subdomains und Benutzer. Aktivierte A- und AAAA-Records werden regelmäßig auf die aktuelle öffentliche IP gesetzt.
## Was es kann
## Starten
- Mehrere Hetzner-Domains mit getrennten, verschlüsselten API-Tokens
- Import vorhandener A-/AAAA-Records und Anlegen neuer DynDNS-Hosts
- Reversibles Trennen von DynDNS-Zielen: Hetzner-Records bleiben bestehen und werden im lokalen Archiv angezeigt
- Globale, konfigurierbare IP-Prüfung mit HTTPS-Fallbackquellen
- Live-Countdown bis zum nächsten Scheduler-Lauf und manueller Sofortlauf für Admins
- Beschreibungen lokal und als Hetzner-Record-Kommentar
- Sichere Mehrbenutzerverwaltung:
- `Admin`: vollständige Zonen-, Token-, Updater- und Benutzerverwaltung
- `Mitglied`: ausschließlich explizit freigegebene Domains oder einzelne Subdomains
- SQLite-Datenvolume, Docker Secret für die Token-Verschlüsselung und kein externer Login-Dienst
Die Datei [`compose.yml`](compose.yml) in Portainer, Dockge oder einem anderen Docker-Webinterface als Stack einfügen und starten.
## Schnellstart
1. Secret-Verzeichnis anlegen und einen neuen Schlüssel erzeugen:
Alternativ im ausgecheckten Repository:
```bash
mkdir -p secrets
openssl rand -base64 32 > secrets/dyndns_master_key
chmod 700 secrets
chmod 644 secrets/dyndns_master_key
cp .env.example .env
docker compose up -d
```
2. Für LAN-Zugriff in `.env` `BIND_ADDRESS` auf die konkrete private IP des Docker-Hosts setzen, zum Beispiel `192.168.178.10`. Verwende im HTTP-LAN-Modus nie `0.0.0.0`.
Danach aufrufen:
Die Schlüsseldatei benötigt bei Docker Compose Leserechte für den unprivilegierten Containerprozess. Das Verzeichnis selbst bleibt deshalb auf dem Host mit Modus `700` geschützt; im Container läuft nur der nicht-root DynDNS-Prozess.
```text
http://IP-DES-DOCKER-HOSTS:8080
```
3. Starten:
Beim ersten Start werden Datenbank und Verschlüsselungsschlüssel automatisch in getrennten Docker-Volumes angelegt. Es werden keine `.env`-Datei und kein manuell erzeugtes Secret benötigt.
## Das Compose
```yaml
services:
dyndns:
image: git.j-mahler.de/admin/domain-control-webinterface-prod:stable
pull_policy: always
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- dyndns-data:/data
- dyndns-secrets:/secrets
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
volumes:
dyndns-data:
dyndns-secrets:
```
Soll ein anderer Port verwendet werden, muss nur die linke Zahl geändert werden, beispielsweise `9000:8080`.
## Aktualisieren
Im Docker-Webinterface das aktuelle Image ziehen und den Stack neu bereitstellen. Auf der Kommandozeile:
```bash
docker compose up -d --build
docker compose pull
docker compose up -d
```
4. Öffne `http://<host-ip>:8080`, lege den ersten Admin-Benutzer an und verbinde eine Zone mit einem Hetzner Cloud API-Token.
Benutzer, Domains und Einstellungen bleiben in `dyndns-data`. Der Schlüssel für gespeicherte API-Tokens liegt in `dyndns-secrets`. Für ein vollständiges Backup müssen beide Volumes gesichert werden.
## Produktivbetrieb
## Funktionen
Für den Produktivserver gibt es eine separate Compose-Datei, die ein versioniertes Image aus der Gitea Container Registry bezieht und niemals lokal baut:
- mehrere Domains und getrennt verschlüsselte Hetzner-API-Tokens
- automatischer Import vorhandener A-/AAAA-Records
- DynDNS pro Subdomain und IP-Familie aktivierbar
- lokales Archiv statt Löschen von Hetzner-Records
- frei einstellbares Update-Intervall mit Countdown
- Admin- und Mitgliedskonten mit Domain- oder Subdomain-Rechten
```bash
docker compose --env-file .env.production -f compose.production.yml pull
docker compose --env-file .env.production -f compose.production.yml up -d
```
Die vollständige Anleitung für Gitea Actions, HTTPS-Reverse-Proxy, Installation, Backups, Updates und Rollbacks steht in [PRODUCTION.md](PRODUCTION.md).
## Sicherheit
- Passwörter werden mit Argon2id gehasht.
- Hetzner-Tokens werden mit XChaCha20-Poly1305 verschlüsselt; der Schlüssel liegt als Docker Secret außerhalb des Datenvolumes.
- Cookies sind `HttpOnly` und `SameSite=Strict`; alle schreibenden Formulare verwenden CSRF-Tokens.
- Token-Anzeige verlangt die erneute Eingabe des Admin-Passworts und setzt `Cache-Control: no-store`.
- Unprivilegierte Nutzer sehen niemals API-Tokens, globale IP-Quellen, Update-Historie anderer Domains oder Benutzerverwaltung.
`APP_MODE=lan` ist nur für ein isoliertes, vertrauenswürdiges Netz geeignet. Passwort und API-Token sind bei HTTP auf einem fremden oder geteilten Netz nicht gegen Mitlesen geschützt. Für WLAN mit Gästen oder externen Zugriff setze einen HTTPS-Reverse-Proxy vor den Container und verwende `APP_MODE=https`.
## Berechtigungen
Ein Admin legt unter **Benutzer** Konten an und entscheidet pro Mitglied:
- **Ganze Domain**: Zugriff auf alle aktuellen und später importierten/angelegten DynDNS-Ziele dieser Zone.
- **Einzelne Subdomain**: Zugriff nur auf den konkreten Host, etwa `home.example.de`.
Mitglieder dürfen ihren freigegebenen Host aktivieren/deaktivieren und dessen Beschreibung pflegen. Sie können keine DNS-Records löschen, Domains importieren, API-Tokens einsehen oder globale Einstellungen verändern.
## Daten und Betrieb
Die persistente Datenbank liegt im Docker-Volume `dyndns-data`. Sichere für Backups sowohl das Volume als auch das separate Master-Key-Secret; ohne den Master-Key lassen sich gespeicherte Hetzner-Tokens absichtlich nicht wiederherstellen.
Der Container bietet `/healthz` und `/readyz`. Für eine Aktualisierung ohne Warten auf das Intervall gibt es im Admin-Dashboard **Jetzt aktualisieren**.
Port 8080 verwendet zunächst unverschlüsseltes HTTP. Den Port daher nur in einem vertrauenswürdigen Netz freigeben und nicht offen ins Internet stellen.
+1 -1
View File
@@ -36,7 +36,7 @@ func run(logger *slog.Logger) error {
if err != nil {
return err
}
cipher, err := secure.LoadCipher(cfg.MasterKeyFile)
cipher, err := secure.LoadOrCreateCipher(cfg.MasterKeyFile)
if err != nil {
return err
}
-39
View File
@@ -1,39 +0,0 @@
name: domain-control
services:
dyndns:
image: ${DYNDNS_IMAGE:-git.j-mahler.de/admin/domain-control-webinterface-prod}:${IMAGE_TAG:-v1.0.0}
restart: unless-stopped
init: true
stop_grace_period: 20s
ports:
- "${BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-8080}:8080"
environment:
APP_MODE: ${APP_MODE:-https}
APP_ADDRESS: :8080
APP_DATA_DIR: /data
APP_MASTER_KEY_FILE: /run/secrets/dyndns_master_key
volumes:
- dyndns-data:/data
secrets:
- dyndns_master_key
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
secrets:
dyndns_master_key:
file: ${MASTER_KEY_FILE:-./secrets/dyndns_master_key}
volumes:
dyndns-data:
name: domain-control-data
+18
View File
@@ -0,0 +1,18 @@
services:
dyndns:
image: git.j-mahler.de/admin/domain-control-webinterface-prod:stable
pull_policy: always
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- dyndns-data:/data
- dyndns-secrets:/secrets
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
volumes:
dyndns-data:
dyndns-secrets:
-32
View File
@@ -1,32 +0,0 @@
services:
dyndns:
build:
context: .
image: local/hetzner-dyndns-dashboard:latest
restart: unless-stopped
ports:
# Use one explicit RFC1918 host address for LAN access, never 0.0.0.0 in HTTP mode.
- "${BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-8080}:8080"
environment:
APP_MODE: ${APP_MODE:-lan}
APP_ADDRESS: :8080
APP_DATA_DIR: /data
APP_MASTER_KEY_FILE: /run/secrets/dyndns_master_key
volumes:
- dyndns-data:/data
secrets:
- dyndns_master_key
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
secrets:
dyndns_master_key:
file: ${MASTER_KEY_FILE:-./secrets/dyndns_master_key}
volumes:
dyndns-data:
+1 -4
View File
@@ -50,10 +50,7 @@ func Load() (Config, error) {
return Config{}, fmt.Errorf("APP_SHUTDOWN_TIMEOUT: %w", err)
}
keyFile := os.Getenv("APP_MASTER_KEY_FILE")
if keyFile == "" {
return Config{}, fmt.Errorf("APP_MASTER_KEY_FILE is required (mount a Docker secret there)")
}
keyFile := value("APP_MASTER_KEY_FILE", "/secrets/master_key")
cookieSecure := mode == "https"
if raw := os.Getenv("APP_COOKIE_SECURE"); raw != "" {
+43
View File
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/crypto/argon2"
@@ -31,6 +32,48 @@ func LoadCipher(path string) (*Cipher, error) {
if err != nil {
return nil, fmt.Errorf("read master key: %w", err)
}
return cipherFromKey(raw)
}
// LoadOrCreateCipher loads an existing master key or creates one on first start.
// The key must live on a persistent volume separate from the application data.
func LoadOrCreateCipher(path string) (*Cipher, error) {
cipher, err := LoadCipher(path)
if err == nil {
return cipher, nil
}
if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create master key directory: %w", err)
}
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("create master key: %w", err)
}
encoded := []byte(base64.RawStdEncoding.EncodeToString(key))
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
return LoadCipher(path)
}
if err != nil {
return nil, fmt.Errorf("create master key file: %w", err)
}
if _, err := file.Write(encoded); err != nil {
_ = file.Close()
_ = os.Remove(path)
return nil, fmt.Errorf("write master key: %w", err)
}
if err := file.Close(); err != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("close master key file: %w", err)
}
return &Cipher{key: key}, nil
}
func cipherFromKey(raw []byte) (*Cipher, error) {
raw = []byte(strings.TrimSpace(string(raw)))
key, err := base64.RawStdEncoding.DecodeString(string(raw))
if err != nil {
+28
View File
@@ -41,6 +41,34 @@ func TestCipherRoundTripAndFingerprint(t *testing.T) {
}
}
func TestLoadOrCreateCipherPersistsSecureKey(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "secrets", "master_key")
first, err := LoadOrCreateCipher(path)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if permissions := info.Mode().Perm(); permissions != 0o600 {
t.Fatalf("master key permissions = %o, want 600", permissions)
}
ciphertext, nonce, err := first.Encrypt("persistent token")
if err != nil {
t.Fatal(err)
}
second, err := LoadOrCreateCipher(path)
if err != nil {
t.Fatal(err)
}
plaintext, err := second.Decrypt(ciphertext, nonce)
if err != nil || plaintext != "persistent token" {
t.Fatalf("created key was not reused: plaintext=%q err=%v", plaintext, err)
}
}
func TestPasswordHash(t *testing.T) {
t.Parallel()
hash, err := HashPassword("a secure password")