Skip to content

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

Open
srgg wants to merge 1 commit into
xreef:masterfrom
srgg:feat/command-hook
Open

Add a universal command hook (FtpResponse facade) + custom cooperative transfers#97
srgg wants to merge 1 commit 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.

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