Skip to content

Add a universal command hook (FtpResponse facade) + custom cooperative transfers - #97

Open
srgg wants to merge 3 commits into
xreef:masterfrom
srgg:feat/command-hook
Open

Add a universal command hook (FtpResponse facade) + custom cooperative transfers#97
srgg wants to merge 3 commits into
xreef:masterfrom
srgg:feat/command-hook

Conversation

@srgg

@srgg srgg commented Jul 16, 2026

Copy link
Copy Markdown

Command hook extension

Problem

Applications currently have no way to influence how the server handles individual FTP commands.

The existing setCallback and setTransferCallback APIs are invoked only after a command has already been processed and return void. They are useful for observing server activity, but they cannot modify, reject, or replace command handling.

As a result, several common extension scenarios are currently impossible or require patching the server implementation:

  • Reject a command — refuse selected verbs, for example to run a read-only server.
  • Rewrite a command — alias one command to another while reusing the built-in handler.
  • Implement a command — handle a custom or extension verb directly, for example returning the server time.
  • Add a custom transfer — stream generated data that no standard FTP command can produce, such as a multi-file bundle or on-the-fly compression.
  • Control session authorization — implement custom authentication and authorization logic, such as validating tokens, one-time codes, or external identity providers, and revoke access to active sessions when required.

Proposal

This PR introduces an optional command hook that gives applications control over the handling of individual FTP commands.

The hook is fully backward-compatible. If no hook is registered, the server behaves exactly as it does today.

API

class FtpResponse {
public:
  void reply(const char* line);                             // send one response line ("550 …"); marks the command done
  void rewriteCommand(const char* cmd, const char* param);  // run a different command instead of this one
  bool isAuthenticated() const;                             // has the client passed USER/PASS?
  void setAuthenticated(bool);                              // let the hook do its own auth (token, IP allow-list, …)
  bool beginCustomTransfer(const CustomTransfer* xfer, void* ctx);
};

// The struct a custom transfer is defined by (passed to beginCustomTransfer):
struct CustomTransfer {
  enum TransferResult { TR_DONE, TR_ABORTED };         // how the transfer ended, passed to onEnd
  const char* name;                                    // label for setTransferCallback (there is no `file`)
  int  (*sendChunk)(void* ctx, Client& data);           // >0 = bytes sent, 0 = nothing yet, -1 = done, <-1 = error
  const char* (*onEnd)(void* ctx, TransferResult r);   // custom final reply line, or nullptr for default (226/426)
};

void setCommandHandler(void (*fn)(FtpResponse& res, const char* command, const char* parameter));

The hook runs for every command, immediately before the server's built-in handler. It does not return a value. Instead, its interaction with FtpResponse determines what happens next:

Hook action Server behavior
res.reply("550 …") The reply is sent and the built-in handler does not run.
res.rewriteCommand(cmd, param) The rewritten command runs instead of the original command.
res.beginCustomTransfer(&xfer, ctx) The custom transfer runs and the built-in handler does not run.
No action The original command runs normally.

Because handling is expressed through FtpResponse, there is no return value to interpret incorrectly. Calling reply() marks the command as handled, so the hook cannot both send a reply and allow the built-in handler to send another one.

FtpResponse intentionally exposes only these operations. Internal server methods such as begin(), end(), handleFTP(), and callback registration are not reachable from the hook.

Custom transfers

beginCustomTransfer() allows a hook to stream data that no standard FTP command can produce, such as generated reports, multi-file bundles, or on-the-fly compression.

Transfers are driven incrementally from handleFTP(), one chunk per call, so the control connection remains responsive and never blocks waiting for the producer.

onEnd() is guaranteed to run exactly once regardless of how the transfer finishes:

  • normal completion
  • ABOR
  • client disconnect
  • dropped connection
  • server shutdown through end()

This provides a single place to close files, release buffers, and perform cleanup.

Transfer byte counts continue flowing through the existing setTransferCallback, so progress reporting and stall watchdogs continue to work for custom transfers.

Safety

  • rewriteCommand copies into the server's fixed command[] and cmdLine[] buffers using bounded operations (strncpy/strnlen). It also handles cases where param points into the same buffer using memmove.
  • Rewritten commands are not passed through the hook again. When rewriting a command, apply policy to the rewritten command as well as the original command.

Examples

Read-only server

Reject write operations while allowing all normal read commands to continue through the built-in handler:

void onCommand(FtpResponse& res, const char* command, const char*) {
  static const char* WRITE[] = {
    "STOR","APPE","STOU","DELE","MKD","RMD","RNFR","RNTO","MFMT"
  };

  for (auto w : WRITE)
    if (strcmp(command, w) == 0) {
      res.reply("550 Read-only server.");
      return;
    }

  // Read commands fall through and run normally.
}

ftp.setCommandHandler(&onCommand);

Command aliasing

Rewrite a command while continuing to use the built-in handler.

For example, RETR LATEST can be translated into a request for the newest file while preserving existing RETR behavior such as resume support and access checks:

void onCommand(FtpResponse& res, const char* command, const char* parameter) {
  if (strcmp(command, "RETR") == 0 &&
      parameter &&
      strcasecmp(parameter, "LATEST") == 0) {

    res.rewriteCommand("RETR", newestFile());

    // No reply: the server runs "RETR <newest>"
  }
}

ftp.setCommandHandler(&onCommand);

Custom authentication

The hook runs before the built-in authentication flow, allowing applications to provide their own authentication mechanism.

For example, the library can still handle USER, while the hook handles PASS using a token, hash, rotating one-time code, or external identity service:

void onCommand(FtpResponse& res, const char* command, const char* parameter) {
  if (strcmp(command, "PASS") == 0) {
    if (parameter && checkToken(parameter)) {
      res.setAuthenticated(true);
      res.reply("230 Login successful.");
    } else {
      res.setAuthenticated(false);
      res.reply("530 Authentication failed.");
    }

    return;
  }
}

ftp.setCommandHandler(&onCommand);

setAuthenticated(true) marks the session as authenticated, allowing subsequent commands to pass the built-in authentication check. isAuthenticated() can be used by hooks that need to inspect the current session state.

Implement a command

Handle a custom or extension command directly without involving a data connection:

void onCommand(FtpResponse& res, const char* command, const char*) {
  if (strcmp(command, "TIME") == 0) {
    char line[40];

    snprintf(line, sizeof(line),
             "211 %lu",
             (unsigned long)time(nullptr));

    res.reply(line);
  }
}

ftp.setCommandHandler(&onCommand);

Custom generated transfer

Serve generated data that does not exist as a file on disk:

struct TransferContext {
  const char* p;
  size_t left;
};

static TransferContext g_ctx;

int sendChunk(void* ctx, Client& data) {
  TransferContext* c = (TransferContext*)ctx;

  if (c->left == 0)
    return -1; // done

  size_t n = data.write((const uint8_t*)c->p, c->left);

  c->p += n;
  c->left -= n;

  return (int)n; // >0 sent, 0 retry later
}

const char* onEnd(void*, TransferResult r) {
  // Cleanup runs exactly once.
  return (r == TR_DONE) ? "226 Report sent." : nullptr;
}

static const CustomTransfer REPORT = {
  "report.txt",
  &sendChunk,
  &onEnd
};

void onCommand(FtpResponse& res, const char* command, const char* parameter) {
  if (strcmp(command, "RETR") == 0 &&
      parameter &&
      strcmp(parameter, "report.txt") == 0) {

    static const char TEXT[] =
      "generated on the fly, no file on disk\n";

    g_ctx = { TEXT, sizeof(TEXT) - 1 };

    res.beginCustomTransfer(&REPORT, &g_ctx);
  }
}

ftp.setCommandHandler(&onCommand);

Compatibility

This change is purely additive.

No existing function signatures change, and when no command hook is installed the server behavior is unchanged.

srgg added 3 commits July 16, 2026 00:59
Add a single extension point, setCommandHandler(FtpResponse&, command,
parameter), invoked before the built-in dispatch for every command. The
hook is void; its disposition is inferred from what it does with the
FtpResponse capability facade:

  reply()              -> command handled, built-in skipped
  rewriteCommand()     -> library runs the rewritten command instead
  beginCustomTransfer  -> caller-driven data transfer, built-in skipped
  (nothing)            -> the original command runs

FtpResponse is a narrow facade (friend of FtpServer) exposing only the
hook-safe ops, so a hook cannot reach begin()/end()/handleFTP() or rebind
itself. reply() being the handled-signal makes "reply then also pass" a
non-issue by construction. rewriteCommand copies are bounded to the
internal command[]/cmdLine[] buffers (strncpy/strnlen + alias-safe memmove).

CustomTransfer streams cooperatively, one chunk per handleFTP() tick, so
the control channel never blocks. onEnd() fires exactly once on every
termination (done, ABOR, disconnect) through the abortTransfer() funnel, so
resources never leak; it returns a custom final line or nullptr for the
library default (226/426).
An app pairs FTP_DOWNLOAD_START/FTP_UPLOAD_START with the terminal callback to
release what it acquired for a transfer — a radio boost, a paused advertisement,
a progress UI. Several endings never delivered that pairing, and several
delivered the wrong one.

Missing terminal callback:
- closeTransfer() emitted it only inside `deltaT > 0 && bytesTransfered > 0`, a
  condition written to guard the throughput division. An empty file, or one that
  finished inside a single millisecond, ended with no notification at all.
- dataConnected() answered 426 and closed the stage without any callback.
- doStore()'s out-of-space path answered 552 and closed the file by hand: no
  callback, no dir close, no restart-position reset.

Success reported for a failure — the client is told 226 and the app is told
FTP_TRANSFER_STOP:
- doRetrieve(): a zero-length socket write, and a peer that closed the data
  connection with bytes still to send (a truncated download).
- doStore(): a peer that stayed connected but sent nothing for 5 s.
- doRetrieve()'s REST seek failure additionally answered twice, 450 then 226.

All of these now end through abortTransfer(), which closes the file and the dir,
fires FTP_TRANSFER_ERROR, resets the restart position and replies once. It takes
an optional reply so a caller with a more specific code than 426 keeps it —
used by the 450 and 552 paths above.

Separately, doRetrieve() dropped data on a short write: file.read() had already
advanced the cursor by the full block, so bytes the socket did not accept were
never sent and the client received a file with a hole in it and no error. The
cursor is now rewound to the first unsent byte.
…ransfer

A zero-length socket write ended a download. It does not mean the peer is gone:
the network client returns zero once its own retry budget expires, which on a
memory-constrained board happens whenever the WiFi driver momentarily cannot
allocate a transmit buffer. The window reopens seconds later. Downloads were
being torn down for a condition that clears by itself — on the board this was
first measured on, a 2.9 MB file died after 32 KB.

doRetrieve() now treats a zero-length write as no progress rather than an
ending: the file cursor is rewound so the same block is retried, the byte count
and the download-progress callback stay put, and the transfer continues. Only
rounds that actually sent something push the idle deadline out, so a peer that
never comes back is ended by that deadline instead of running forever.

The deadline could not do that job before. It was the tail of the if/else chain
in handleFTP(), and a running transfer always took its own branch first — so it
was unreachable during exactly the case that now needs bounding. It is now a
check of its own, scoped to an idle command connection or a RETR: the other
transfer types never refresh the deadline, and bounding them here would kill
them mid-progress.
@srgg
srgg force-pushed the feat/command-hook branch from fda28a1 to d7c5cf0 Compare August 11, 2026 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant