9.2 KiB
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
<!-- Aktuelle Version prüfen: https://mvnrepository.com/artifact/com.hierynomus/sshj -->
<dependency>
<groupId>com.hierynomus</groupId>
<artifactId>sshj</artifactId>
<version>0.38.0</version>
</dependency>
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<FileEntry>
│
├─ 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)
@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<String> privateKeyPath();
Optional<String> privateKeyPassphrase();
}
SftpService — Implementierungsstruktur
@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> T withSftp(SftpOperation<T> 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<String> 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> {
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:
# 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)
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-LogikFileProcessingPipeline: Einziger Orchestrierer — kennt alle Services, steuert den Ablauf- Services: Keine Abhängigkeiten untereinander — jeder isoliert testbar
- Config:
@ConfigMapping— alle Werte ausapplication.properties/ Umgebungsvariablen
Testing-Strategie
SftpService: Testcontainers + echtem SFTP-Container (atmoz/sftpauf Docker Hub)ZipExtractionService,OciUploadService: Unit-Tests mit Testdaten / MocksFileProcessingPipeline: 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)
# 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 plan_n8n_replacement_quarkus.md für vollständige Konfigurationsübersicht.