292 lines
9.2 KiB
Markdown
292 lines
9.2 KiB
Markdown
|
|
# 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
|
||
|
|
<!-- 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)
|
||
|
|
|
||
|
|
```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<String> privateKeyPath();
|
||
|
|
Optional<String> 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> 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:
|
||
|
|
|
||
|
|
```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 `plan_n8n_replacement_quarkus.md` für vollständige Konfigurationsübersicht.
|