From a55e9983aa1904332467eb4db61fbed32c4f597f Mon Sep 17 00:00:00 2001 From: Nicolas Joubert Date: Wed, 22 Jul 2026 16:51:31 +0200 Subject: [PATCH 1/2] cleverage/flysystem-process-bundle#24 - Add sftp-short and sftp-mock containers on docker stack, used by processesd demo.sftp_fetch_archive_timeout and demo.sftp_stale_connection --- .claude/CLAUDE.md | 2 + .docker/compose.yaml | 28 +++- .docker/sftp-mock/Dockerfile | 8 + .docker/sftp-mock/server.py | 158 ++++++++++++++++++ .docker/sftp/short-timeout.sh | 12 ++ .env | 6 +- .gitignore | 1 + config/packages/cleverage_process.yaml | 4 + config/packages/flysystem.yaml | 50 +++++- .../demo.sftp_fetch_archive_timeout.yaml | 60 +++++++ .../process/demo.sftp_stale_connection.yaml | 56 +++++++ 11 files changed, 380 insertions(+), 5 deletions(-) create mode 100644 .docker/sftp-mock/Dockerfile create mode 100644 .docker/sftp-mock/server.py create mode 100755 .docker/sftp/short-timeout.sh create mode 100644 config/packages/process/demo.sftp_fetch_archive_timeout.yaml create mode 100644 config/packages/process/demo.sftp_stale_connection.yaml diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index a13566d..5c75de5 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co A Symfony 7.4 demo project showcasing [`cleverage/process-bundle`](https://github.com/cleverage/process-bundle) v5 and its bridge bundles (archive, cache, doctrine, flysystem, rest, soap, ui). The "product" here is not PHP code — it is the **process configurations** under `config/packages/process/`. Each file demonstrates one feature of the process ecosystem. `src/` contains only a handful of glue classes (entities, fixtures, a few custom adapters/DTOs). +> **Convention — how features are exercised here.** Features (including bug reproductions and regression checks) must be demonstrated **primarily through `clever_age_process` configurations**, each named with a `demo.` prefix and placed under `config/packages/process/` (one file per process, auto-imported). Reach for a throwaway PHP script or a custom `src/` command only as a last resort when a behaviour genuinely cannot be expressed as a process; prefer composing existing tasks and (generic) transformers instead. Example: issue-#24 SFTP reproduction lives in `config/packages/process/demo.sftp_stale_connection.yaml`. + ## Environment & commands Everything runs inside Docker (`.docker/compose.yaml`), driven by the `Makefile`. Do **not** run PHP/composer/console directly on the host — wrap them in the container. The Makefile targets already do this via `docker compose ... run --rm php-fpm`. diff --git a/.docker/compose.yaml b/.docker/compose.yaml index c038555..ebe45be 100644 --- a/.docker/compose.yaml +++ b/.docker/compose.yaml @@ -80,11 +80,35 @@ services: retries: 100 ports: - "${MYSQL_PORT:-3306}:3306" + sftp: <<: *base-sftp tty: true - ports: - - "${SFTP_PORT:-22}:22" + + # Same atmoz/sftp image as `sftp`, but with a deliberately short sshd idle + # timeout (see sftp/short-timeout.sh). Used by demo.sftp_stale_connection to + # reproduce flysystem-process-bundle issue #24; all other demo.sftp_* processes + # keep using the normal `sftp` service. + sftp-short: + <<: *base-sftp + tty: true + volumes: + - ./sftp/data:/home/sftp/data + - ./sftp/short-timeout.sh:/etc/sftp.d/short-timeout.sh:ro + + # Mock SFTP server (paramiko) that, after a short idle, injects a stray SFTP + # packet while keeping the SSH transport open - reproducing the client's SAP + # server behaviour and thus the VERBATIM issue #24 error + # ("Expected NET_SFTP_HANDLE or NET_SFTP_STATUS. Got packet type"). + sftp-mock: + build: + context: sftp-mock + tty: true + volumes: + - ./sftp/data:/data + environment: + SFTP_MOCK_ROOT: / + SFTP_MOCK_IDLE: "4" volumes: process_bundle_demo_data: diff --git a/.docker/sftp-mock/Dockerfile b/.docker/sftp-mock/Dockerfile new file mode 100644 index 0000000..7e3b585 --- /dev/null +++ b/.docker/sftp-mock/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim + +RUN pip install --no-cache-dir "paramiko>=3,<4" + +COPY server.py /server.py + +EXPOSE 22 +CMD ["python", "/server.py"] diff --git a/.docker/sftp-mock/server.py b/.docker/sftp-mock/server.py new file mode 100644 index 0000000..86c3729 --- /dev/null +++ b/.docker/sftp-mock/server.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +Minimal mock SFTP server (paramiko) used to reproduce the EXACT flysystem-process-bundle +issue #24 error "Expected NET_SFTP_HANDLE or NET_SFTP_STATUS. Got packet type". + +It serves files normally, but after a short idle period on an established SFTP +session it injects one stray SFTP packet (SSH_FXP_NAME) onto the channel WHILE +KEEPING THE SSH TRANSPORT OPEN. This mimics enterprise SFTP servers (e.g. the +client's SAP server) that expire the SFTP session / leave a stray or late response +instead of tearing down the TCP transport the way OpenSSH does. phpseclib then reads +that stray packet on the next reused operation and raises the issue #24 error +(whereas a clean OpenSSH disconnect yields "Connection closed prematurely"). +""" +import os +import socket +import threading +import time + +import paramiko +from paramiko.sftp import CMD_NAME + +ROOT = os.environ.get("SFTP_MOCK_ROOT", "/data") +IDLE = float(os.environ.get("SFTP_MOCK_IDLE", "4")) +HOST_KEY = paramiko.RSAKey.generate(2048) + + +class Server(paramiko.ServerInterface): + def check_auth_password(self, username, password): + if username == "sftp" and password == "password": + return paramiko.AUTH_SUCCESSFUL + return paramiko.AUTH_FAILED + + def get_allowed_auths(self, username): + return "password" + + def check_channel_request(self, kind, chanid): + if kind == "session": + return paramiko.OPEN_SUCCEEDED + return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED + + +class StubSFTPHandle(paramiko.SFTPHandle): + def stat(self): + try: + return paramiko.SFTPAttributes.from_stat(os.fstat(self.readfile.fileno())) + except OSError as e: + return paramiko.SFTPServer.convert_errno(e.errno) + + +class StubSFTPServer(paramiko.SFTPServerInterface): + def _realpath(self, path): + return ROOT + self.canonicalize(path) + + def list_folder(self, path): + p = self._realpath(path) + out = [] + for fname in os.listdir(p): + attr = paramiko.SFTPAttributes.from_stat(os.stat(os.path.join(p, fname))) + attr.filename = fname + out.append(attr) + return out + + def stat(self, path): + try: + return paramiko.SFTPAttributes.from_stat(os.stat(self._realpath(path))) + except OSError as e: + return paramiko.SFTPServer.convert_errno(e.errno) + + def lstat(self, path): + try: + return paramiko.SFTPAttributes.from_stat(os.lstat(self._realpath(path))) + except OSError as e: + return paramiko.SFTPServer.convert_errno(e.errno) + + def open(self, path, flags, attr): + p = self._realpath(path) + try: + fd = os.open(p, flags, 0o666) + except OSError as e: + return paramiko.SFTPServer.convert_errno(e.errno) + if flags & os.O_WRONLY: + fstr = "ab" if flags & os.O_APPEND else "wb" + elif flags & os.O_RDWR: + fstr = "a+b" if flags & os.O_APPEND else "r+b" + else: + fstr = "rb" + try: + f = os.fdopen(fd, fstr) + except OSError as e: + return paramiko.SFTPServer.convert_errno(e.errno) + fobj = StubSFTPHandle(flags) + fobj.filename = p + fobj.readfile = f + fobj.writefile = f + return fobj + + +class InjectingSFTPServer(paramiko.SFTPServer): + """Serves normally, then injects one stray SFTP packet after IDLE seconds.""" + + def start_subsystem(self, name, transport, channel): + self._last = time.time() + self._seen = False + self._injected = False + self._alive = True + threading.Thread(target=self._watch, daemon=True).start() + try: + super().start_subsystem(name, transport, channel) + finally: + self._alive = False + + def _read_packet(self): + self._last = time.time() + self._seen = True + return super()._read_packet() + + def _watch(self): + while getattr(self, "_alive", False): + time.sleep(0.5) + if self._seen and not self._injected and (time.time() - self._last) > IDLE: + try: + # Stray SSH_FXP_NAME (type 104): body just needs valid framing; + # phpseclib reads the type, expects HANDLE/STATUS, and throws. + msg = paramiko.Message() + msg.add_int(0) # request id + msg.add_int(0) # count + self._send_packet(CMD_NAME, msg) + print("[sftp-mock] injected stray NAME packet after idle", flush=True) + except Exception as exc: # noqa: BLE001 + print(f"[sftp-mock] inject failed: {exc}", flush=True) + self._injected = True + + +def handle(client, addr): + try: + t = paramiko.Transport(client) + t.add_server_key(HOST_KEY) + t.set_subsystem_handler("sftp", InjectingSFTPServer, StubSFTPServer) + t.start_server(server=Server()) + while t.is_active(): + time.sleep(1) + except Exception as exc: # noqa: BLE001 + print(f"[sftp-mock] session error: {exc}", flush=True) + + +def main(): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("", 22)) + sock.listen(10) + print(f"[sftp-mock] listening on :22 root={ROOT} idle={IDLE}s", flush=True) + while True: + client, addr = sock.accept() + threading.Thread(target=handle, args=(client, addr), daemon=True).start() + + +if __name__ == "__main__": + main() diff --git a/.docker/sftp/short-timeout.sh b/.docker/sftp/short-timeout.sh new file mode 100755 index 0000000..0cc8b77 --- /dev/null +++ b/.docker/sftp/short-timeout.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Shorten the sshd idle timeout for this dedicated SFTP service so that +# demo.sftp_stale_connection can reproduce flysystem-process-bundle issue #24 +# (stale cached SFTP connection) in a few seconds instead of the ~60 minutes +# reported in the wild. Run by the atmoz/sftp entrypoint (/etc/sftp.d/*) before +# sshd starts. Only this "short timeout" SFTP service mounts it; the normal +# `sftp` service keeps OpenSSH defaults. +set -e +{ + echo "ClientAliveInterval 3" + echo "ClientAliveCountMax 1" +} >> /etc/ssh/sshd_config diff --git a/.env b/.env index b28e0a2..d80163d 100644 --- a/.env +++ b/.env @@ -39,10 +39,14 @@ DATABASE_URL="mysql://app:app@mysql:3306/app?serverVersion=9.1.0&charset=utf8mb4 ###> docker/atmoz-sftp ### SFTP_HOST=sftp -SFTP_PORT=22 SFTP_USERNAME=sftp SFTP_PASSWORD=password SFTP_ROOT=data +# Dedicated SFTP service with a short sshd idle timeout (see .docker/sftp/short-timeout.sh). +SFTP_SHORT_HOST=sftp-short +# Mock SFTP server that injects a stray packet after idle (see .docker/sftp-mock/), to +# reproduce the verbatim issue #24 error. Serves /data as its root (SFTP root is empty). +SFTP_MOCK_HOST=sftp-mock ###< docker/atmoz-sftp ### ###> symfony/routing ### diff --git a/.gitignore b/.gitignore index 0b2f59e..3499663 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /composer.lock .idea .composer +.bash_history ###> symfony/framework-bundle ### /.env.local diff --git a/config/packages/cleverage_process.yaml b/config/packages/cleverage_process.yaml index 3d139f9..9635d58 100644 --- a/config/packages/cleverage_process.yaml +++ b/config/packages/cleverage_process.yaml @@ -31,4 +31,8 @@ clever_age_process: callback: callback: substr right_parameters: [ '{{ offset }}', '{{ length }}' ] + sleep: # pause for $value seconds (PHP sleep) then pass the value through + transformers: + callback: + callback: sleep diff --git a/config/packages/flysystem.yaml b/config/packages/flysystem.yaml index f70675c..fc5b02c 100644 --- a/config/packages/flysystem.yaml +++ b/config/packages/flysystem.yaml @@ -11,7 +11,53 @@ flysystem: adapter: 'sftp' options: host: '%env(string:SFTP_HOST)%' - port: '%env(int:SFTP_PORT)%' + port: 22 username: '%env(string:SFTP_USERNAME)%' password: '%env(string:SFTP_PASSWORD)%' - root: '%env(string:SFTP_ROOT)%' \ No newline at end of file + root: '%env(string:SFTP_ROOT)%' + + # `sftp-short` container (OpenSSH with a short sshd idle timeout). Two distinct + # storages (different roots) on the same host, following the .incoming / + # .archive convention. Because OpenSSH closes the idle transport cleanly, + # this yields "Connection closed prematurely". Used by demo.sftp_stale_connection. + remote.storage.short_timeout.incoming: + adapter: 'sftp' + options: + host: '%env(string:SFTP_SHORT_HOST)%' + port: 22 + username: '%env(string:SFTP_USERNAME)%' + password: '%env(string:SFTP_PASSWORD)%' + root: '%env(string:SFTP_ROOT)%/incoming' + + remote.storage.short_timeout.archive: + adapter: 'sftp' + options: + host: '%env(string:SFTP_SHORT_HOST)%' + port: 22 + username: '%env(string:SFTP_USERNAME)%' + password: '%env(string:SFTP_PASSWORD)%' + root: '%env(string:SFTP_ROOT)%/archive' + + # `sftp-mock` container: a paramiko server that injects a stray packet after idle + # while keeping the SSH transport open (like the client's SAP server). Two distinct + # storages (different roots) on the same host, following the .incoming / + # .archive convention. Being different storages, each gets its OWN + # SftpConnectionProvider (its OWN phpseclib connection). This yields the VERBATIM + # issue #24 error. Used by demo.sftp_fetch_archive_timeout. + remote.storage.mock.incoming: + adapter: 'sftp' + options: + host: '%env(string:SFTP_MOCK_HOST)%' + port: 22 + username: '%env(string:SFTP_USERNAME)%' + password: '%env(string:SFTP_PASSWORD)%' + root: '%env(string:SFTP_ROOT)%/incoming' + + remote.storage.mock.archive: + adapter: 'sftp' + options: + host: '%env(string:SFTP_MOCK_HOST)%' + port: 22 + username: '%env(string:SFTP_USERNAME)%' + password: '%env(string:SFTP_PASSWORD)%' + root: '%env(string:SFTP_ROOT)%/archive' diff --git a/config/packages/process/demo.sftp_fetch_archive_timeout.yaml b/config/packages/process/demo.sftp_fetch_archive_timeout.yaml new file mode 100644 index 0000000..dcb1488 --- /dev/null +++ b/config/packages/process/demo.sftp_fetch_archive_timeout.yaml @@ -0,0 +1,60 @@ +clever_age_process: + configurations: + demo.sftp_fetch_archive_timeout: + description: > + Minimal reproduction of the client's erp_contact_import failure (flysystem-process-bundle issue #24), + keeping only the essential shape: get_file fans out (in parallel) to [treatment, archive_file]. + Pass the get_file storage BASE via the `filesystem` context; get_file uses `{{ filesystem }}.incoming` + and archive_file uses `{{ filesystem }}.archive` (the archive storage is deduced from the base - option + templating is a plain substitution, so the suffix is appended). They are two DIFFERENT flysystem + storages (different roots) on the SAME host, so each has its OWN cached phpseclib connection; + archive_file opens a FRESH connection and does NOT reuse get_file's one. + The stale connection is get_file's own (`{{ filesystem }}.incoming`): opened by get_file.execute, left + idle during the treatment sleep, and re-hit by get_file.next() - FileFetchTask is iterable, so after the + outputs are processed it lists the source again on the now-stale connection and fails. This matches the + client report ("archiving task and subsequent file listing operations"). + With `filesystem: remote.storage.mock` the sftp-mock server injects a stray packet after idle while + keeping the transport open (like the client's SAP server), yielding the VERBATIM issue #24 error + "Expected NET_SFTP_HANDLE or NET_SFTP_STATUS. Got packet type". + help: > + bin/console cleverage:process:execute demo.sftp_fetch_archive_timeout -c filesystem:remote.storage.mock + tasks: + get_file: + service: '@CleverAge\FlysystemProcessBundle\Task\FileFetchTask' + description: > + Fetch the source file from `{{ filesystem }}.incoming` (opens + caches connection C_A). Mirrors + the client's get_zip; iterable (file_pattern), fans out to treatment and archive_file (LAST). + options: + source_filesystem: '{{ filesystem }}.incoming' + destination_filesystem: 'local.storage' + file_pattern: '/sample.*\.txt$/' + remove_source: false + ignore_missing: false + outputs: [treatment, debug, archive_file] + treatment: + service: '@CleverAge\ProcessBundle\Task\ConstantOutputTask' + description: > + Long-running treatment placeholder (feeds the idle duration in seconds to the sleep + transformer). Must exceed the server idle threshold. + options: + output: 10 + outputs: [treatment_sleep] + treatment_sleep: + service: '@CleverAge\ProcessBundle\Task\TransformerTask' + description: > + Keeps connection C_A idle (generic `sleep` transformer). Dead-end branch. + options: + transformers: + sleep: ~ + debug: + service: '@CleverAge\ProcessBundle\Task\Debug\DebugTask' + archive_file: + service: '@CleverAge\FlysystemProcessBundle\Task\FileFetchTask' + description: > + Archive the file by reading from `{{ filesystem }}.archive` - a DIFFERENT storage (deduced from + the base), so this opens a FRESH connection C_B. Mirrors the client's archive_file. + options: + source_filesystem: '{{ filesystem }}.incoming' + destination_filesystem: '{{ filesystem }}.archive' + remove_source: false + ignore_missing: false diff --git a/config/packages/process/demo.sftp_stale_connection.yaml b/config/packages/process/demo.sftp_stale_connection.yaml new file mode 100644 index 0000000..0cbf89a --- /dev/null +++ b/config/packages/process/demo.sftp_stale_connection.yaml @@ -0,0 +1,56 @@ +clever_age_process: + configurations: + demo.sftp_stale_connection: + description: > + Same shape as demo.sftp_fetch_archive_timeout (get_file -> treatment sleep -> archive_file, with + get_file re-listing its source on next()), but pointed at the OpenSSH `sftp-short` server via + `filesystem: remote.storage.short_timeout`. get_file uses `{{ filesystem }}.incoming` and archive_file + uses `{{ filesystem }}.archive` (archive storage deduced from the base). + This reproduces the ROOT CAUSE of flysystem-process-bundle issue #24 - a stale, cached SFTP connection + reused after the server timed out the idle session - but against OpenSSH the manifestation is + "Connection closed prematurely" (OpenSSH closes the idle transport cleanly with SSH_MSG_DISCONNECT, so + phpseclib hits EOF before reading a packet). For the VERBATIM issue-#24 wording + "Expected NET_SFTP_HANDLE or NET_SFTP_STATUS. Got packet type", run demo.sftp_fetch_archive_timeout + against `remote.storage.mock`, whose server leaves a stray packet on an open transport instead. + help: > + bin/console cleverage:process:execute demo.sftp_stale_connection -c filesystem:remote.storage.short_timeout + tasks: + get_file: + service: '@CleverAge\FlysystemProcessBundle\Task\FileFetchTask' + description: > + Fetch the source file from `{{ filesystem }}.incoming` (opens + caches the connection). + Iterable (file_pattern); fans out to treatment and archive_file (archive_file LAST). + options: + source_filesystem: '{{ filesystem }}.incoming' + destination_filesystem: 'local.storage' + file_pattern: '/sample.*\.txt$/' + remove_source: false + ignore_missing: false + outputs: [treatment, debug, archive_file] + treatment: + service: '@CleverAge\ProcessBundle\Task\ConstantOutputTask' + description: > + Long-running treatment placeholder (idle duration in seconds). Must exceed the server idle + threshold (sftp-short drops idle clients after ~6s). + options: + output: 10 + outputs: [treatment_sleep] + treatment_sleep: + service: '@CleverAge\ProcessBundle\Task\TransformerTask' + description: > + Keeps the cached connection idle (generic `sleep` transformer). Dead-end branch. + options: + transformers: + sleep: ~ + debug: + service: '@CleverAge\ProcessBundle\Task\Debug\DebugTask' + archive_file: + service: '@CleverAge\FlysystemProcessBundle\Task\FileFetchTask' + description: > + Archive by reading from `{{ filesystem }}.archive` - a DIFFERENT storage (deduced from the + base), so this opens a FRESH connection. + options: + source_filesystem: '{{ filesystem }}.incoming' + destination_filesystem: '{{ filesystem }}.archive' + remove_source: false + ignore_missing: false From 9f0f9a3da447cd1f2d4b8db29f9060325e9992c1 Mon Sep 17 00:00:00 2001 From: Nicolas Joubert Date: Wed, 22 Jul 2026 18:03:01 +0200 Subject: [PATCH 2/2] cleverage/flysystem-process-bundle#24 - Fix sftp connections issues using SftpConnectivityChecker --- config/packages/flysystem.yaml | 4 +++ src/Flysystem/SftpConnectivityChecker.php | 40 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 src/Flysystem/SftpConnectivityChecker.php diff --git a/config/packages/flysystem.yaml b/config/packages/flysystem.yaml index fc5b02c..9fd5b33 100644 --- a/config/packages/flysystem.yaml +++ b/config/packages/flysystem.yaml @@ -28,6 +28,7 @@ flysystem: username: '%env(string:SFTP_USERNAME)%' password: '%env(string:SFTP_PASSWORD)%' root: '%env(string:SFTP_ROOT)%/incoming' + connectivityChecker: App\Flysystem\SftpConnectivityChecker remote.storage.short_timeout.archive: adapter: 'sftp' @@ -37,6 +38,7 @@ flysystem: username: '%env(string:SFTP_USERNAME)%' password: '%env(string:SFTP_PASSWORD)%' root: '%env(string:SFTP_ROOT)%/archive' + connectivityChecker: App\Flysystem\SftpConnectivityChecker # `sftp-mock` container: a paramiko server that injects a stray packet after idle # while keeping the SSH transport open (like the client's SAP server). Two distinct @@ -52,6 +54,7 @@ flysystem: username: '%env(string:SFTP_USERNAME)%' password: '%env(string:SFTP_PASSWORD)%' root: '%env(string:SFTP_ROOT)%/incoming' + connectivityChecker: App\Flysystem\SftpConnectivityChecker remote.storage.mock.archive: adapter: 'sftp' @@ -61,3 +64,4 @@ flysystem: username: '%env(string:SFTP_USERNAME)%' password: '%env(string:SFTP_PASSWORD)%' root: '%env(string:SFTP_ROOT)%/archive' + connectivityChecker: App\Flysystem\SftpConnectivityChecker diff --git a/src/Flysystem/SftpConnectivityChecker.php b/src/Flysystem/SftpConnectivityChecker.php new file mode 100644 index 0000000..f002b05 --- /dev/null +++ b/src/Flysystem/SftpConnectivityChecker.php @@ -0,0 +1,40 @@ +isConnected()) { + return false; + } + + try { + // A transport-level ping() (SSH2::ping opens a separate keep-alive + // channel) cannot detect a de-synchronized SFTP stream: if the server + // left a stray/late packet on the SFTP channel, ping() still succeeds. + // So probe at the SFTP protocol level - if the stream is desynced this + // round-trip reads the stray packet and throws, and we force a reconnect. + $connection->stat('.'); + + return true; + } catch (\Throwable) { + return false; + } + } +}