FTP
Crate hopf-ftp: FTP / FTPS server and callback-driven client, a port of Gumdrop org.bluezoo.gumdrop.ftp. The server runs on the Hopf thread-per-core reactor; data connections use dynamic PASV listeners on a shared Arc<Runtime>. TLS is provided by hopf-core::tls (in-tree, on AWS-LC). Authentication uses hopf-auth TrustPolicy.
Contents
Standards
| Reference | Role in Hopf |
|---|---|
| RFC 959 | Base FTP: control commands, TYPE/STRU/MODE, NVFS concepts |
| RFC 2428 | EPRT / EPSV (IPv6-capable active/passive) |
| RFC 2389 | FEAT / OPTS |
| RFC 2640 | OPTS UTF8 ON/OFF pathname charset |
| RFC 3659 | SIZE, MDTM, MLST/MLSD (TVFS facts) |
| RFC 4217 | AUTH TLS, PBSZ, PROT; implicit FTPS |
Architecture
Client ──TCP──► FtpControlHandler (reactor)
│ USER/PASS → TrustPolicy
│ RETR/STOR/LIST → FtpFileSystem (via StorageExecutor for FS)
│
├── PASV/EPSV → Runtime::add_tcp_listener (ephemeral data port)
└── PORT/EPRT → outbound data connect (bounce guard)
- Control connection — one
FtpControlHandlerper accept; commands are lexed incrementally (FtpServerLexer). - Data connection — active (
PORT/EPRT) or passive (PASV/EPSV). Passive binds allocate a short-lived listener on the sameRuntime. - Filesystem — stock path is chrooted
BasicFtpFileSystem; apps can supply anyFtpFileSystem. Blocking FS work should stay off the reactor (storage pool). - TLS — cleartext, explicit
AUTH TLS, or implicit FTPS (securefrom first byte). OptionalPROT Pfor data channels.
Supported commands
Access control
USER, PASS, ACCT (always 202), CWD / XCWD, CDUP / XCUP, QUIT, REIN
Transfer parameters
PORT, PASV, EPRT, EPSV, TYPE (A / I), STRU (F only), MODE (S only)
Service commands
RETR, STOR, STOU (server-generated unique name, actually unique per call — see FtpFileSystem::generate_unique_name), APPE, ALLO (dispatched to FtpFileSystem::allocate_space; 202 by default, matching Gumdrop's own no-op default), REST, ABOR, DELE, RMD / XRMD, MKD / XMKD, PWD / XPWD, LIST, NLST, STAT, HELP, NOOP, SYST, RNFR / RNTO, SITE (dispatched to FtpConnectionHandler::handle_site_command; 502 by default)
Extensions
| Command | Notes |
|---|---|
| FEAT | Feature negotiation |
| SIZE | File size (RFC 3659 §4) |
| MDTM | Modification time YYYYMMDDhhmmss UTC |
| MLST / MLSD | Machine listings (size, modify, type, perm) |
| OPTS UTF8 ON/OFF | Pathname encoding (RFC 2640) |
| AUTH TLS / AUTH SSL | Upgrade control to TLS |
| PBSZ | Protection buffer size (must be 0 before PROT) |
| PROT C / P | Clear / protected data |
Rejected / not implemented: CCC, SMNT (appropriate error replies).
Data connection modes
- Active (PORT / EPRT) — server connects to the client-specified address. Bounce protection rejects PORT targets that are not the control peer unless
allow_active_bounceis set. - Passive (PASV / EPSV) — client connects to a server-advertised address. Prefer EPSV for IPv6 and NAT. Optional
pasv_advertisedIP andpasv_port_min/pasv_port_maxfor firewall/NAT deployments.
FEAT advertisement
When FEAT is issued, the server advertises (among others):
UTF8
SIZE
MDTM
MLST Type*;Size*;Modify*;Perm*;
MLSD
REST STREAM
EPSV
EPRT
AUTH TLS
PBSZ
PROT
Security (FTPS)
Explicit AUTH TLS
C: AUTH TLS
S: 234 AUTH TLS successful
[TLS handshake]
C: USER alice
…
Configure with FtpConfig::with_tls(acceptor) (explicit) — the control listener uses with_starttls_acceptor so the first bytes remain cleartext until AUTH TLS.
Implicit FTPS
FtpConfig::implicit_ftps() (or implicit_tls = true) expects TLS from connection establishment. Wire the listener with with_tls (TLS-from-accept). Conventionally listen on port 990 when not overridden.
Data protection
C: PBSZ 0
S: 200 PBSZ=0
C: PROT P
S: 200 Protection level set to P
PROT P— encrypt data connections. Passive-mode (PASV/EPSV) connections use the same TLS acceptor as the control channel (FtpConfig::with_tls); active-mode (PORT/EPRT) connections need a separate client-role connector, since there the server dials out — set one withFtpConfig::with_data_tls_connector(connector, server_name). Without a connector configured,PROT Ptransfers only work in passive mode; an active-mode transfer attempted underPROT Pwith no connector configured is refused (425) rather than silently falling back to cleartext.PROT C— clear data.require_tls_for_data/require_data_tls()— reject (522) a PASV/EPSV/PORT/EPRT attempt made withoutPROT Palready in effect, rather than silently upgrading the connection.
Same-IP data check
Incoming PASV/EPSV data connections are checked against the control connection's remote address as they connect — a peer other than the control connection's own IP is refused outright, regardless of transfer mode or TLS. Active PORT/EPRT bounce to a non-peer address is rejected at the PORT/EPRT command itself unless allow_active_bounce is enabled.
Authentication
Stock FilesystemFtpHandler evaluates TrustPolicy with IdentityMaterial::UsernamePassword after USER/PASS.
use std::sync::Arc;
use hopf_auth::PasswordTrustPolicy;
use hopf_ftp::{FtpConfig, FtpService};
use hopf_core::{Runtime, RuntimeConfig};
let policy = PasswordTrustPolicy::default()
.with_user("ftp", "ftp")
.shared();
let config = FtpConfig::new("127.0.0.1:2121".parse()?, "/tmp/ftp-root".into(), policy);
let service = FtpService::new(config);
let rt = Arc::new(Runtime::start(RuntimeConfig::default())?);
let bound = service.start(Arc::clone(&rt))?;Custom handlers return FtpAuthResult::{Success, NeedPassword, NeedAccount, Failed, Unavailable}. See Auth.
File system handler
| Type | Purpose |
|---|---|
FtpFileSystem |
NVFS trait: cwd, list, retrieve, store, mkdir, … |
BasicFtpFileSystem |
Local chroot under root |
FilesystemFtpHandler |
TrustPolicy + BasicFtpFileSystem |
FilesystemFtpHandlerFactory |
Stock factory for FtpService::new |
TransferType |
Ascii / Image |
FtpFileInfo / FtpFileOpResult / DirectoryChange |
Metadata / results |
FilesystemFtpHandler::new(root, policy) and ::read_only(root, policy).
Configuration
FtpConfig
| Property | Type | Default | Notes |
|---|---|---|---|
listen |
SocketAddr |
caller | Typical cleartext :21, demo 127.0.0.1:2121 |
root |
PathBuf |
caller | Chroot for stock FS handler |
policy |
Arc<dyn TrustPolicy> |
caller | USER/PASS evaluation |
tls_acceptor |
Option<SharedTlsAcceptor> |
None |
SharedTlsAcceptor from hopf-core::tls PEM builders (acceptor_from_pem, …) |
data_tls_connector |
Option<SharedTlsConnector> |
None |
Protects active-mode (PORT/EPRT) PROT P data connections — the server dials out, so it needs a connector, not an acceptor |
data_tls_server_name |
Option<String> |
None (falls back to "ftp-client") |
Fixed SNI/verification name for every active-mode TLS dial |
implicit_tls |
bool |
false |
Implicit FTPS when true |
require_tls_for_data |
bool |
false |
Reject (522) a data connection attempted without PROT P already in effect |
allow_active_bounce |
bool |
false |
Allow PORT ≠ control peer |
pasv_advertised |
Option<IpAddr> |
None |
NAT public IP in PASV reply |
pasv_port_min |
Option<u16> |
None |
PASV port range lower |
pasv_port_max |
Option<u16> |
None |
PASV port range upper |
read_only |
bool |
false |
Stock FS write denied |
Builders: FtpConfig::new(listen, root, policy), .with_tls(acceptor), .with_data_tls_connector(connector, server_name), .implicit_ftps(), .require_data_tls().
FtpService
| Method | Notes |
|---|---|
FtpService::new(config) |
Stock FilesystemFtpHandlerFactory |
with_handler_factory(config, factory) |
Custom SPI |
control_listener(runtime) |
Build TcpListenerConfig without starting |
start(runtime) |
Bind + register; returns bound SocketAddr |
metrics() |
FtpServerMetrics snapshot |
Handler SPI
pub trait FtpConnectionHandler: Send {
fn welcome_message(&self, meta: &FtpConnectionMetadata) -> Option<String>;
fn authenticate(
&mut self,
username: &str,
password: Option<&str>,
account: Option<&str>,
meta: &FtpConnectionMetadata,
) -> FtpAuthResult;
fn file_system(&mut self, meta: &FtpConnectionMetadata) -> &mut dyn FtpFileSystem;
fn is_authorized(
&self,
op: FtpOperation, // Read | Write | Delete | CreateDir | DeleteDir | Rename | Navigate | SiteCommand | Admin
path: &str,
meta: &FtpConnectionMetadata,
) -> bool;
fn handle_site_command(
&mut self,
command: &str,
meta: &FtpConnectionMetadata,
) -> FtpFileOpResult; // default: NotSupported (502)
fn disconnected(&mut self, meta: &FtpConnectionMetadata); // default: no-op
}
pub trait FtpConnectionHandlerFactory: Send + Sync {
fn create(&self) -> Box<dyn FtpConnectionHandler>;
}FtpConnectionMetadata: peer, local, user, tls. is_authorized now gates CWD/CDUP (Navigate), MKD (CreateDir), RMD (DeleteDir), and RNFR/RNTO (Rename) in addition to RETR/LIST/STAT (Read), STOR/APPE/STOU (Write), and DELE (Delete); SiteCommand/Admin are available for handlers to use from within handle_site_command itself (the framework doesn't auto-gate SITE, matching Gumdrop).
Transfer progress (Gumdrop transferStarting/transferProgress/transferCompleted): transfer_starting(path, upload, size, meta) fires synchronously on the control connection before RETR/STOR/APPE/STOU open a data connection. Per-chunk and completion notifications go through a separate transfer_observer(meta) -> Option<Arc<dyn TransferObserver>> instead of more FtpConnectionHandler methods — RETR runs on a StorageExecutor thread and STOR/APPE/STOU on the data connection's own reactor thread, neither of which is the control connection's thread where the rest of the handler lives, so TransferObserver is Send + Sync and obtained once per transfer rather than called back through &mut self.
Quota (Gumdrop getQuotaManager/canStore/getQuota/recordBytesAdded/recordBytesRemoved): quota_manager() -> Option<Arc<dyn hopf_core::QuotaManager>> plus default-implemented can_store/quota/record_bytes_added/record_bytes_removed delegating to it — see Quota. STOR/APPE/STOU are rejected (552) up front if the user is already over quota; usage is recorded once the upload completes, via the quota handle carried alongside the transfer (same cross-thread reasoning as TransferObserver). The stock FilesystemFtpHandler/FilesystemFtpHandlerFactory take one via with_quota(Arc<dyn QuotaManager>).
Client
Callback-driven FtpClient on an Arc<Runtime>. DNS (when needed) and the control/data session run on worker reactors; connect returns immediately. Drive the session with an FtpPipeline — stock pipelines FtpGet (RETR) and FtpPut (STOR).
FtpClientTimeouts
| Property | Type | Default | Notes |
|---|---|---|---|
dns |
duration | 5s | Hostname resolve budget (ignored for literal IPs) |
connect |
duration | 30s | TCP connect handshake |
stage |
duration | 60s | Control-channel reply per command |
data |
duration | 600s | Data-transfer budget (RETR / STOR / LIST) |
FtpClient
| Builder | Notes |
|---|---|
FtpClient::new(host) |
Hostname or IP string; default port 21 |
.port(u16) |
Override control port |
.credentials(user, pass) |
USER / PASS |
.timeouts(FtpClientTimeouts) |
Override defaults |
.prefer_epsv(bool) |
Prefer EPSV over PASV (default true) |
.resolver(Arc<DnsResolver>) |
Reuse a resolver |
.connect(&Arc<Runtime>, pipeline) |
Schedule DNS/dial; returns immediately |
use std::sync::Arc;
use std::time::Duration;
use hopf_core::{Runtime, RuntimeConfig};
use hopf_ftp::{FtpClient, FtpClientTimeouts, FtpGet};
let rt = Arc::new(Runtime::start(RuntimeConfig::default())?);
let pipeline = FtpGet::new("/readme.txt", |r| {
match r {
Ok(bytes) => eprintln!("got {} bytes", bytes.len()),
Err(e) => eprintln!("RETR failed: {e}"),
}
});
FtpClient::new("127.0.0.1")
.port(2121)
.credentials("ftp", "ftp")
.timeouts(FtpClientTimeouts {
dns: Duration::from_secs(5),
connect: Duration::from_secs(10),
stage: Duration::from_secs(30),
data: Duration::from_secs(120),
})
.connect(&rt, Box::new(pipeline))?;FtpGet / FtpPut sequence: auth (from credentials) → TYPE I → PASV/EPSV → RETR or STOR → QUIT. Custom workflows implement FtpPipeline (start / done / failed) and issue ops via FtpSessionWrite.
Metrics
Process-local FtpServerMetrics (in hopf-ftp): connections, auth_ok / auth_fail, commands, bytes_in / bytes_out, and pasv_binds via FtpService::metrics().
OTLP/JSONL export (when FtpService::with_telemetry(&pipeline) is used): hopf_otel::FtpServerMetrics emits ftp.server.connections, ftp.server.active_connections, ftp.server.auth, ftp.server.commands, ftp.server.pasv_binds, ftp.server.transfers (direction/outcome), ftp.server.transfer.duration (ms), and ftp.server.transfer.size.
With traces enabled on the pipeline, FtpConnectionMetadata::traceparent carries the active W3C traceparent for the connection or current data transfer (RETR/STOR/LIST/MLSD). Handler SPI implementers can pass it to outbound HTTP with hopf_otel::with_traceparent. Connection duration is not duplicated on metadata — use telemetry Accept→Close timestamps.
Examples
See Cookbook: FTP.
# Cleartext stock server (user/pass ftp/ftp)
cargo run -p ftp-server -- 127.0.0.1:2121 /tmp/ftp-root
# Async client RETR
FTP_USER=ftp FTP_PASS=ftp cargo run -p ftp-get -- 127.0.0.1:2121 /hello.txt| Package | Demonstrates | Knobs |
|---|---|---|
examples/ftp (ftp-server) |
Stock FS server | addr (def 127.0.0.1:2121), root |
examples/ftp-get |
Async FtpClient + FtpGet |
host[:port], path; FTP_USER / FTP_PASS |
Limitations
- CCC and SMNT are not implemented (rejected); SITE is dispatched to the application (
FtpConnectionHandler::handle_site_command,502by default — no subcommands are built in). - Stock examples are cleartext; FTPS is available via API +
tls.mdbut not wired in the demo binaries. - The quota gate can only check "is the user already over quota" before a STOR/APPE/STOU, not "would this specific upload push them over" —
ALLOreachesFtpFileSystem::allocate_space(so a backend can reject an unreasonable declared size there), but that call isn't automatically wired into the STOR/APPE/STOU quota check that follows it; a backend wanting precise pre-emption would need to correlate the two itself. See Quota. - Client completion is callback-driven — callers wait on their own signalling (see
examples/ftp-get).