# SSHJ SFTP — Architektur-Übersicht **Projekt:** n8n Replacement für Dateieingang-Verarbeitung **Stand:** 2026-04-08 --- ## Warum SSHJ statt Camel Quarkus FTP? Camel Quarkus FTP ist ein EIP-/Routing-Framework, das intern selbst eine SFTP-Bibliothek wrapped. Da der Trigger ein REST-Endpoint ist (kein eigenes Scheduling, kein Message Routing), bringt Camel nur Overhead: | | Camel Quarkus FTP | SSHJ | |---|---|---| | **Zweck** | EIP-Framework (Routing, EIP-Patterns, Scheduling) | SFTP/SSH Client | | **Größe** | Camel Core + FTP-Komponente (~10+ MByte Abhängigkeiten) | Leichtgewichtig | | **API** | Camel Exchange, RouteBuilder, Endpoints | Einfache Java API | | **Wartbarkeit** | Camel-Kenntnisse nötig | Standard Java I/O-Konzepte | | **Quarkus-Integration** | Native Extension vorhanden | Plain CDI-Bean | | **Eignet sich für** | Komplexe Routing-Szenarien | SFTP-Operationen (list, get, rename) | Für diesen Use-Case (list ZIPs, download, rename nach Verarbeitung) ist SSHJ die richtige Wahl. --- ## Abhängigkeit ```xml com.hierynomus sshj 0.38.0 ``` SSHJ bringt Bouncy Castle als transitive Abhängigkeit mit (für Kryptografie). Das ist kein Problem — Bouncy Castle ist weit verbreitet und gut gepflegt. --- ## Pipeline-Architektur ``` REST POST /api/process-incoming (X-Api-Key Header) │ ▼ 202 Accepted (sofort) FileProcessingPipeline.processAllAsync() │ Hintergrund-Thread (ManagedExecutor) │ ├─ SftpService.listZipFiles() │ └─ sftp.ls(remotePath) → ["export_2026-04-08.zip", ...] │ └─ für jede ZIP: │ ├─ SftpService.download(filename) │ └─ sftp.get(remotePath/file, localPath) │ ├─ ZipExtractionService.extract(localFile) │ └─ ProcessingContext mit List │ ├─ OciUploadService.upload(context) │ └─ Dateien + Marker in OCI (SimpleAuthenticationDetailsProvider) │ ├─ SftpService.renameRemote(file, file + ".processed") ← bei Erfolg │ SftpService.renameRemote(file, file + ".error") ← bei Fehler │ ├─ OrdsNotificationService.notify(context) │ └─ cleanup: lokale ZIP + Entpack-Verzeichnis löschen ← immer (finally) ``` --- ## SftpConfig (@ConfigMapping) ```java @ConfigMapping(prefix = "galabau.sftp") public interface SftpConfig { String host(); int port(); String username(); String password(); String hostKeyFingerprint(); // z.B. "SHA256:AbCdEfGh..." String remotePath(); String localWorkDir(); Optional privateKeyPath(); Optional privateKeyPassphrase(); } ``` --- ## SftpService — Implementierungsstruktur ```java @ApplicationScoped public class SftpService { @Inject SftpConfig config; /** * Stellt eine SFTP-Verbindung her, führt die Operation aus und trennt danach sauber. * Host-Key wird gegen konfigurierten Fingerprint geprüft — kein PromiscuousVerifier. */ private T withSftp(SftpOperation operation) throws SftpException { try (SSHClient ssh = new SSHClient()) { // Fingerprint-basierte Host-Key-Verifikation (konfigurierbar, sicher) ssh.addHostKeyVerifier(new FingerprintVerifier(config.hostKeyFingerprint())); ssh.connect(config.host(), config.port()); if (config.privateKeyPath().isPresent()) { // Public-Key-Auth (bevorzugt für Produktion) ssh.authPublickey(config.username(), config.privateKeyPath().get()); } else { // Password-Auth (Fallback / Entwicklung) ssh.authPassword(config.username(), config.password()); } try (SFTPClient sftp = ssh.newSFTPClient()) { return operation.execute(sftp); } } catch (IOException e) { throw new SftpException("SFTP operation failed: " + e.getMessage(), e); } } public List listZipFiles() throws SftpException { return withSftp(sftp -> sftp.ls(config.remotePath()).stream() .filter(entry -> entry.getName().endsWith(".zip")) .map(RemoteResourceInfo::getName) .toList() ); } public Path download(String filename) throws SftpException { Path localFile = Path.of(config.localWorkDir(), filename); withSftp(sftp -> { sftp.get(config.remotePath() + "/" + filename, localFile.toString()); return null; }); return localFile; } public void renameRemote(String filename, String newFilename) throws SftpException { withSftp(sftp -> { sftp.rename( config.remotePath() + "/" + filename, config.remotePath() + "/" + newFilename ); return null; }); } } @FunctionalInterface interface SftpOperation { T execute(SFTPClient sftp) throws IOException; } ``` --- ## Host-Key-Fingerprint ermitteln Einmalig ausführen (z.B. aus WSL oder Git Bash), bevor der Service deployed wird: ```bash # Alle Host Keys des Servers anzeigen (SHA256 Fingerprints): ssh-keyscan sftp.lieferant.de 2>/dev/null | ssh-keygen -lf - # Ausgabe z.B.: # 256 SHA256:AbCdEfGhIjKlMnOpQrStUv... sftp.lieferant.de (ED25519) # 2048 SHA256:XyZaBcDeFgHiJk... sftp.lieferant.de (RSA) ``` Den `SHA256:...`-Teil in `galabau.sftp.host-key-fingerprint` eintragen. **ED25519 bevorzugen** (stärker und kürzer als RSA). --- ## Fehlerbehandlung ### SFTP-Verbindungsfehler (transient) ``` IOException beim connect/auth/ls → SftpException (transient) → Pipeline bricht ab → Nächster APEX-Lauf (1h) versucht es erneut → Alle ZIPs bleiben auf SFTP unverändert ``` ### Fehler während ZIP-Verarbeitung ``` ZIP entpackbar? Nein → ZIP umbenennen zu .error, weiter mit nächster ZIP OCI Upload erfolgreich? Nein (transient 503) → @Retry (3x), danach ZIP zu .error Nein (persistent 403) → ZIP zu .error, Log — Credentials prüfen! ORDS-Aufruf fehlgeschlagen? Marker liegt vor → kein Problem, APEX Automation findet ihn nächsten Lauf ``` ### Cleanup (immer) ```java try { // ... Verarbeitung ... } finally { // Lokale Dateien immer löschen — egal ob Erfolg oder Fehler Files.deleteIfExists(context.localZipPath); deleteDirectory(context.localExtractDir); MDC.clear(); } ``` --- ## Transient vs. Persistent Fehler | Fehler | Typ | Handling | |---|---|---| | SFTP-Verbindung timeout/refused | transient | Nächster APEX-Lauf | | OCI 503 Service Unavailable | transient | @Retry (3x mit Backoff) | | ZIP beschädigt | persistent | `.error`, Log | | OCI 403 Auth Failed | persistent | `.error`, Log — Config prüfen! | | SFTP-Rename fehlgeschlagen | transient | Log, ZIP bleibt — nächster Lauf | --- ## n8n → Quarkus Service: Feature-Mapping | n8n | Quarkus/SSHJ | Anmerkung | |---|---|---| | SFTP Trigger (Polling) | entfällt — REST Endpoint | Scheduling bleibt bei APEX Automation | | SFTP Download | `SftpService.download()` | SSHJ `sftp.get()` | | Unzip | `ZipExtractionService` | Apache Commons Compress | | HTTP PUT (OCI) | `OciUploadService` | OCI SDK, Instance Principal | | HTTP POST (ORDS) | `OrdsNotificationService` | MicroProfile REST Client | | Try/Catch | Try/Catch + @Retry | Kein Framework-Overhead | | Rename | `SftpService.renameRemote()` | SSHJ `sftp.rename()` | | Error Notification | Log + OTLP | Loki/Grafana Alert | --- ## Wartungs-Punkte ### Code-Organisation - **`SftpService`:** Nur SFTP-Operationen (list, download, rename) — keine Business-Logik - **`FileProcessingPipeline`:** Einziger Orchestrierer — kennt alle Services, steuert den Ablauf - **Services:** Keine Abhängigkeiten untereinander — jeder isoliert testbar - **Config:** `@ConfigMapping` — alle Werte aus `application.properties` / Umgebungsvariablen ### Testing-Strategie - `SftpService`: Testcontainers + echtem SFTP-Container (`atmoz/sftp` auf Docker Hub) - `ZipExtractionService`, `OciUploadService`: Unit-Tests mit Testdaten / Mocks - `FileProcessingPipeline`: Integration-Test mit Mock-Services - E2E: gegen Staging-SFTP und Staging-OCI ### Monitoring - OTLP → Loki (via Quarkus OpenTelemetry) - Dashboards: ZIP-Verarbeitungsrate, Fehler-Rate, Latenz pro Step - Alerts: `.error`-Dateien > Schwellwert, ORDS-Ausfälle, Pipeline-Laufzeit > Schwellwert --- ## Konfiguration (Zusammenfassung) ```properties # Host-Key Fingerprint (ssh-keyscan host | ssh-keygen -lf -) galabau.sftp.host-key-fingerprint=SHA256:AbCdEfGh... # Password aus Env-Var (Quarkus löst ${...} nativ auf) galabau.sftp.password=${GALABAU_SFTP_PASSWORD} # OCI: Credentials aus Env-Vars, Private Key als gemountetes Kubernetes Secret # galabau.oci.tenancy-id, user-id, fingerprint aus ${ENV_VAR} # galabau.oci.private-key-path zeigt auf gemountete PEM-Datei # API-Absicherung des Endpoints galabau.api.key=${GALABAU_API_KEY} ``` Siehe `docs/Plan.md` für vollständige Konfigurationsübersicht.