From 49ddaeccff3d25794312ca378c90ca5426d8a51b Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 3 Aug 2026 21:53:35 +0200 Subject: [PATCH] Docker-Compose-Setup ohne benannte Volumes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Container bringt die gebaute Anwendung mit; einziger Mount ist ./data:/app/data. Die Buchhaltung bleibt damit als gewöhnliche SQLite-Dateien im Projektverzeichnis und lässt sich ohne Docker sichern und einsehen. Der Server band bisher fest an 127.0.0.1 und wäre im Container von außen nicht erreichbar gewesen. Die Adresse ist jetzt über HOST einstellbar und bleibt außerhalb des Containers unverändert auf 127.0.0.1; im Container gilt 0.0.0.0, abgesichert über eine Portfreigabe, die nur auf 127.0.0.1 lauscht. Weitere Festlegungen: - Lauf unter der UID des aufrufenden Kontos, damit ./data nicht root gehört - schreibgeschütztes Containerdateisystem, beschreibbar nur /app/data und /tmp - .env wird nicht ins Image gebacken, sondern von docker-compose eingelesen - ERiC bleibt außen vor und ist bei Bedarf vom Host einzubinden Die Startmeldung nannte bisher nur, ob eine .env-Datei existiert. Im Container gibt es keine, obwohl die Konfiguration vollständig vorliegt – gemeldet wird jetzt der tatsächliche ERiC-Zustand. Co-Authored-By: Claude Opus 5 (1M context) --- .dockerignore | 26 ++++++++++++++++++ Dockerfile | 56 ++++++++++++++++++++++++++++++++++++++ README.md | 41 ++++++++++++++++++++++++++++ docker-compose.yml | 66 +++++++++++++++++++++++++++++++++++++++++++++ server/src/index.ts | 28 +++++++++++++++---- 5 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..436a98a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# Nichts davon gehört in den Build-Kontext – teils groß, teils vertraulich. +node_modules +**/node_modules +dist +**/dist +*.tsbuildinfo + +# Buchhaltungsdaten kommen zur Laufzeit per Bind-Mount, nicht ins Image +data + +# Zugangsdaten: .env wird von docker-compose eingelesen, nicht eingebacken +.env +.env.* +!.env.beispiel +*.pfx +*.p12 +*.pem +*.key + +.git +.gitignore +*.log +.vscode +.idea +Dockerfile +docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..580c568 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# syntax=docker/dockerfile:1 + +# Debian-Basis statt Alpine: better-sqlite3 ist ein natives Modul und findet +# für glibc fertige Binaries. Unter musl müsste es im Image übersetzt werden. +FROM node:22-bookworm-slim AS builder + +WORKDIR /app + +# Erst die Manifeste kopieren – solange sie sich nicht ändern, bleibt die +# Installationsschicht im Cache und ein Codeänderung baut in Sekunden. +COPY package.json package-lock.json ./ +COPY server/package.json ./server/ +COPY web/package.json ./web/ + +RUN npm ci + +COPY server ./server +COPY web ./web + +RUN npm run build + +# Entwicklungsabhängigkeiten entfernen, das übersetzte better-sqlite3 bleibt +# erhalten. Ein erneutes npm ci im Laufzeit-Image würde es neu bauen müssen. +RUN npm prune --omit=dev + + +FROM node:22-bookworm-slim AS runtime + +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=3311 + +WORKDIR /app + +# Die Anwendung leitet ihre Pfade aus dem Ort von server/dist ab +# (siehe server/src/pfade.ts). Deshalb muss die Ordnerstruktur erhalten bleiben: +# /app/server/dist, /app/web/dist und /app/data. +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/server/package.json ./server/package.json +COPY --from=builder /app/server/dist ./server/dist +COPY --from=builder /app/web/dist ./web/dist +COPY .env.beispiel ./.env.beispiel + +# Der Node-Basisimage-Benutzer hat bereits UID 1000 und passt damit zu einem +# typischen Desktop-Konto – die Dateien in ./data bleiben so dem Host gehören. +RUN mkdir -p /app/data && chown -R node:node /app/data +USER node + +EXPOSE 3311 + +# Ohne curl im Image: die Prüfung läuft über Node selbst. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3311)+'/api/gesundheit').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "server/dist/index.js"] diff --git a/README.md b/README.md index 1c13c96..d99ea7b 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,47 @@ die Beispieldaten ein zweites Mal eingespielt und alle Auswertungen verdoppeln. `npm run seed -- reset` setzt die Bewegungsdaten zurück und legt sie neu an; dabei gehen auch selbst erfasste Buchungen verloren. +## Betrieb mit Docker + +```bash +cp .env.beispiel .env # optional, für ELSTER +export UID GID=$(id -g) # damit ./data dem eigenen Konto gehört +docker-compose up -d --build +``` + +Danach läuft die Anwendung auf . + +**Keine benannten Volumes.** Der einzige Mount ist `./data:/app/data` – die +Buchhaltung liegt als gewöhnliche SQLite-Dateien im Projektverzeichnis und lässt +sich sichern, kopieren und einsehen, ohne Docker zu bemühen. Der Container läuft +mit der UID des aufrufenden Kontos, deshalb gehören die Dateien nicht root. + +Weitere Festlegungen: + +- **Port nur auf 127.0.0.1 veröffentlicht.** Die Anwendung kennt keine + Benutzeranmeldung, und hier liegen Steuernummer und Umsätze. Für den Zugriff + aus dem Netz gehört ein Reverse Proxy mit Authentifizierung davor. +- Das Dateisystem des Containers ist schreibgeschützt (`read_only`), beschreibbar + ist nur `/app/data` und ein `tmpfs` unter `/tmp`. +- `.env` wird **nicht** ins Image gebacken. `docker-compose` liest die Datei für + die Variablenersetzung und reicht die Werte als Umgebungsvariablen weiter. +- **ERiC ist nicht enthalten** und darf es auch nicht sein. Wer echt übermitteln + will, hebt in `docker-compose.yml` die Zeilen für `/opt/eric` und das + Zertifikat auf und setzt die Pfade in `.env`. + +Beim Start meldet der Container, woher die Konfiguration stammt und ob ERiC +eingebunden ist: + +``` +open-eur-webapp läuft auf http://localhost:3311 +Konfiguration aus der Prozessumgebung. +ELSTER: Simulationsmodus, es wird nichts übermittelt (…). +Mandanten: standard – aktiv ohne Angabe: standard +``` + +Ohne Docker bindet sich der Server weiterhin an `127.0.0.1`; im Container setzt +`HOST=0.0.0.0`, weil die Portfreigabe den Prozess sonst nicht erreicht. + ## Mandanten Jeder Mandant hat **eine eigene SQLite-Datei** unter `data/mandanten/`. Das ist diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..102cfa0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +# Aufruf: docker-compose up -d --build +# +# Die Anwendung läuft dann unter http://localhost:3311 +# +# Es werden bewusst keine benannten Volumes verwendet: die Buchhaltung liegt als +# gewöhnliche SQLite-Dateien im Projektverzeichnis unter ./data und lässt sich +# damit sichern, kopieren und einsehen, ohne Docker zu bemühen. +version: "3.8" + +services: + euer: + build: + context: . + dockerfile: Dockerfile + image: open-eur-buchhaltung:latest + container_name: open-eur-buchhaltung + restart: unless-stopped + + # An 127.0.0.1 gebunden: die Anwendung kennt keine Benutzeranmeldung, und + # hier liegen Steuernummer und Umsätze. Für den Zugriff aus dem Netz gehört + # ein Reverse Proxy mit Authentifizierung davor. + ports: + - "127.0.0.1:3311:3311" + + # Mit der UID des aufrufenden Kontos laufen, damit die Dateien in ./data + # dem Host gehören und nicht root. UID/GID notfalls fest eintragen. + user: "${UID:-1000}:${GID:-1000}" + + volumes: + # Einziger Mount: die Daten bleiben im Projektverzeichnis. + - ./data:/app/data + + # ERiC ist proprietär und darf nicht ins Image. Wer echt übermitteln will, + # bindet die Installation vom Host ein und setzt die Pfade in .env. + # - /opt/eric:/opt/eric:ro + # - ./zertifikat.pfx:/app/zertifikat.pfx:ro + + environment: + # Im Container muss auf allen Schnittstellen gelauscht werden, sonst + # erreicht die Portfreigabe den Prozess nicht. + HOST: 0.0.0.0 + PORT: 3311 + NODE_ENV: production + TZ: Europe/Berlin + + # Die folgenden Werte kommen aus ./.env, sofern vorhanden. docker-compose + # liest diese Datei automatisch für die Ersetzung ein; fehlt sie, greifen + # die Vorgaben und die Anwendung läuft im ELSTER-Simulationsmodus. + LOG_LEVEL: "${LOG_LEVEL:-warn}" + EUER_DB: "${EUER_DB:-./data/euer.sqlite}" + EUER_MANDANT: "${EUER_MANDANT:-}" + ERIC_LIB_PATH: "${ERIC_LIB_PATH:-}" + ERIC_PLUGIN_PATH: "${ERIC_PLUGIN_PATH:-}" + ERIC_LOG_PATH: "${ERIC_LOG_PATH:-./data/eric-log}" + ELSTER_ZERTIFIKAT: "${ELSTER_ZERTIFIKAT:-}" + ELSTER_PIN: "${ELSTER_PIN:-}" + ELSTER_HERSTELLER_ID: "${ELSTER_HERSTELLER_ID:-}" + ELSTER_TESTMERKER: "${ELSTER_TESTMERKER:-}" + + # Die Anwendung schreibt außerhalb von /app/data nichts. + read_only: true + tmpfs: + - /tmp + + security_opt: + - no-new-privileges:true diff --git a/server/src/index.ts b/server/src/index.ts index dd1add5..87aa30b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -15,6 +15,7 @@ import { schliesseAlleVerbindungen, standardMandant, } from './mandanten.js'; +import { ericStatus } from './elster/eric.js'; import { mandantenRouten, mandantenScoping } from './routes/mandanten.js'; import { stammdatenRouten } from './routes/stammdaten.js'; import { buchungsRouten } from './routes/buchungen.js'; @@ -59,13 +60,30 @@ for (const signal of ['SIGINT', 'SIGTERM'] as const) { }); } +/* + * Standardmäßig nur auf dem Rechner selbst erreichbar – hier liegen Steuerdaten. + * Im Container ist 0.0.0.0 nötig, sonst kommt keine Verbindung von außen an; + * die Absicherung übernimmt dort die Portfreigabe (127.0.0.1:3311:3311). + */ +const host = process.env.HOST ?? '127.0.0.1'; const port = Number(process.env.PORT ?? 3311); -await app.listen({ port, host: '127.0.0.1' }); -console.log(`open-eur-webapp läuft auf http://127.0.0.1:${port}`); +await app.listen({ port, host }); +if (host !== '127.0.0.1' && host !== 'localhost') { + console.log( + `Achtung: Der Server lauscht auf ${host} und ist damit nicht auf diesen Rechner beschränkt.`, + ); +} +console.log(`open-eur-webapp läuft auf http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`); +// Im Container gibt es keine .env – die Konfiguration kommt über die Umgebung. +// Deshalb wird der tatsächliche ERiC-Zustand gemeldet, nicht nur die Dateilage. +const eric = ericStatus(); console.log( - envDatei - ? `Konfiguration aus ${envDatei} geladen.` - : 'Keine .env gefunden – ELSTER läuft im Simulationsmodus (Vorlage: .env.beispiel).', + envDatei ? `Konfiguration aus ${envDatei} geladen.` : 'Konfiguration aus der Prozessumgebung.', +); +console.log( + eric.verfuegbar + ? `ELSTER: ERiC eingebunden${eric.testmodus ? ' (Testmerker gesetzt)' : ''}.` + : `ELSTER: Simulationsmodus, es wird nichts übermittelt (${eric.meldung}).`, ); if (start.uebernommen) { console.log('Vorhandene data/euer.sqlite als Mandant "standard" übernommen (Datei unverändert).');