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).');