Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8728746f1 | ||
|
|
888d9a54a7 |
No files matched your search
@@ -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
|
|
||||||
@@ -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
@@ -15,7 +15,7 @@ FROM alpine:3.23.2 AS runtime
|
|||||||
RUN apk add --no-cache ca-certificates && \
|
RUN apk add --no-cache ca-certificates && \
|
||||||
addgroup -S -g 10001 dyndns && \
|
addgroup -S -g 10001 dyndns && \
|
||||||
adduser -S -D -H -u 10001 -G dyndns 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
|
COPY --from=build /out/dyndns-web /usr/local/bin/dyndns-web
|
||||||
|
|
||||||
|
|||||||
-170
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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
|
Die Datei [`compose.yml`](compose.yml) in Portainer, Dockge oder einem anderen Docker-Webinterface als Stack einfügen und starten.
|
||||||
- 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
|
|
||||||
|
|
||||||
## Schnellstart
|
Alternativ im ausgecheckten Repository:
|
||||||
|
|
||||||
1. Secret-Verzeichnis anlegen und einen neuen Schlüssel erzeugen:
|
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
3. Starten:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Öffne `http://<host-ip>:8080`, lege den ersten Admin-Benutzer an und verbinde eine Zone mit einem Hetzner Cloud API-Token.
|
|
||||||
|
|
||||||
## Produktivbetrieb
|
|
||||||
|
|
||||||
Für den Produktivserver gibt es eine separate Compose-Datei, die ein versioniertes Image aus der Gitea Container Registry bezieht und niemals lokal baut:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --env-file .env.production -f compose.production.yml pull
|
docker compose up -d
|
||||||
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).
|
Danach aufrufen:
|
||||||
|
|
||||||
## Sicherheit
|
```text
|
||||||
|
http://IP-DES-DOCKER-HOSTS:8080
|
||||||
|
```
|
||||||
|
|
||||||
- Passwörter werden mit Argon2id gehasht.
|
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.
|
||||||
- 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`.
|
## Das Compose
|
||||||
|
|
||||||
## Berechtigungen
|
```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
|
||||||
|
|
||||||
Ein Admin legt unter **Benutzer** Konten an und entscheidet pro Mitglied:
|
volumes:
|
||||||
|
dyndns-data:
|
||||||
|
dyndns-secrets:
|
||||||
|
```
|
||||||
|
|
||||||
- **Ganze Domain**: Zugriff auf alle aktuellen und später importierten/angelegten DynDNS-Ziele dieser Zone.
|
Soll ein anderer Port verwendet werden, muss nur die linke Zahl geändert werden, beispielsweise `9000:8080`.
|
||||||
- **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.
|
## Aktualisieren
|
||||||
|
|
||||||
## Daten und Betrieb
|
Im Docker-Webinterface das aktuelle Image ziehen und den Stack neu bereitstellen. Auf der Kommandozeile:
|
||||||
|
|
||||||
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.
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
Der Container bietet `/healthz` und `/readyz`. Für eine Aktualisierung ohne Warten auf das Intervall gibt es im Admin-Dashboard **Jetzt aktualisieren**.
|
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.
|
||||||
|
|
||||||
|
## Funktionen
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
Port 8080 verwendet zunächst unverschlüsseltes HTTP. Den Port daher nur in einem vertrauenswürdigen Netz freigeben und nicht offen ins Internet stellen.
|
||||||
@@ -36,7 +36,7 @@ func run(logger *slog.Logger) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cipher, err := secure.LoadCipher(cfg.MasterKeyFile)
|
cipher, err := secure.LoadOrCreateCipher(cfg.MasterKeyFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
@@ -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:
|
|
||||||
@@ -50,10 +50,7 @@ func Load() (Config, error) {
|
|||||||
return Config{}, fmt.Errorf("APP_SHUTDOWN_TIMEOUT: %w", err)
|
return Config{}, fmt.Errorf("APP_SHUTDOWN_TIMEOUT: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
keyFile := os.Getenv("APP_MASTER_KEY_FILE")
|
keyFile := value("APP_MASTER_KEY_FILE", "/secrets/master_key")
|
||||||
if keyFile == "" {
|
|
||||||
return Config{}, fmt.Errorf("APP_MASTER_KEY_FILE is required (mount a Docker secret there)")
|
|
||||||
}
|
|
||||||
|
|
||||||
cookieSecure := mode == "https"
|
cookieSecure := mode == "https"
|
||||||
if raw := os.Getenv("APP_COOKIE_SECURE"); raw != "" {
|
if raw := os.Getenv("APP_COOKIE_SECURE"); raw != "" {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/crypto/argon2"
|
"golang.org/x/crypto/argon2"
|
||||||
@@ -31,6 +32,48 @@ func LoadCipher(path string) (*Cipher, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read master key: %w", err)
|
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)))
|
raw = []byte(strings.TrimSpace(string(raw)))
|
||||||
key, err := base64.RawStdEncoding.DecodeString(string(raw))
|
key, err := base64.RawStdEncoding.DecodeString(string(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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) {
|
func TestPasswordHash(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
hash, err := HashPassword("a secure password")
|
hash, err := HashPassword("a secure password")
|
||||||
|
|||||||
Reference in new issue
Block a user