Files
gala-ki-spielwiese/agent-backend/docs/SFTP-Integration.md

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-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)

# 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.