FTP Server
May 17, 2026 ยท View on GitHub
BinktermPHP includes a standalone FTP daemon for QWK packet exchange and file-area access.
This document covers daemon startup, .env configuration, passive FTP network
requirements, and how to expose the service on standard FTP port 21 without
running the daemon as root.
Features
- Standalone daemon entrypoint:
scripts/ftp_daemon.php - Disabled by default
- Normal BBS username/password login
- Anonymous FTP login for public file areas only
- Virtual filesystem for:
/qwk/download/<BBSID>.QWK/qwk/upload/*.REPor/qwk/upload/*.ZIP/incoming/<AREA>/.../fileareas/...
- Per-user upload/download logging
- Passive FTP (
PASV/EPSV) - Active FTP (
PORT/EPRT) - Single-process
stream_select()loop, so it works on Windows too
Requirements
- PHP 8.2+
- BinktermPHP installed and configured
- File Areas enabled if you want
/incomingor/fileareas - QWK enabled if you want
/qwk/downloador/qwk/upload
Default Ports
| Purpose | Default |
|---|---|
| FTP control port | 2121 |
| Passive port range | 2122-2149 |
The daemon intentionally does not bind to privileged port 21 by default.
That avoids needing to run the service as root.
Enable It
The FTP daemon is disabled by default. Set this in .env:
FTPD_ENABLED=true
If FTPD_ENABLED=false, scripts/ftp_daemon.php exits immediately.
Configuration
Add or review these settings in .env:
FTPD_ENABLED=true
FTPD_BIND_HOST=0.0.0.0
FTPD_PORT=2121
FTPD_PUBLIC_HOST=bbs.example.com
FTPD_PASSIVE_PORT_START=2122
FTPD_PASSIVE_PORT_END=2149
FTPD_ALLOW_ANONYMOUS=false
Variables
| Variable | Default | Description |
|---|---|---|
FTPD_ENABLED | false | Enables the standalone FTP daemon |
FTPD_BIND_HOST | 0.0.0.0 | Control-socket bind address |
FTPD_PORT | 2121 | FTP control port |
FTPD_PUBLIC_HOST | empty | Hostname or IPv4 address advertised in passive replies |
FTPD_PASSIVE_PORT_START | 2122 | First passive data port |
FTPD_PASSIVE_PORT_END | 2149 | Last passive data port |
FTPD_ALLOW_ANONYMOUS | false | Allow anonymous (anonymous/ftp) logins |
FTPD_PUBLIC_HOST
Set FTPD_PUBLIC_HOST when:
- the daemon is behind NAT
- the daemon binds to
0.0.0.0 - clients connect through a public hostname or public IP different from the machine's local address
Example:
FTPD_PUBLIC_HOST=ftp.example.com
If this is wrong, clients often log in successfully but fail on LIST, RETR,
or STOR.
Active Mode Support
The daemon now supports classic active-mode data connections through both:
PORTfor IPv4 clientsEPRTfor IPv4 or IPv6 clients
For safety, active mode is restricted so the requested data connection target must match the same remote IP address already connected on the FTP control socket. This prevents FTP bounce behavior where a client asks the server to open data connections to unrelated third-party hosts.
If the active target does not match the control connection IP, the daemon rejects the command.
Starting the Daemon
Foreground:
php scripts/ftp_daemon.php
Background daemon mode:
php scripts/ftp_daemon.php --daemon
Defaults:
- PID file:
data/run/ftpd.pid - Log file:
data/logs/ftpd.log
Show options:
php scripts/ftp_daemon.php --help
Command Line Options
| Option | Default | Description |
|---|---|---|
--host=HOST | FTPD_BIND_HOST or 0.0.0.0 | FTP bind host |
--port=PORT | FTPD_PORT or 2121 | FTP control port |
--public-host=HOST | FTPD_PUBLIC_HOST | Host/IP advertised in passive replies |
--pasv-start=PORT | FTPD_PASSIVE_PORT_START or 2122 | First passive data port |
--pasv-end=PORT | FTPD_PASSIVE_PORT_END or 2149 | Last passive data port |
--daemon | off | Background daemon mode |
--pid-file=FILE | data/run/ftpd.pid | PID file path |
--log-file=FILE | data/logs/ftpd.log | Log file path |
--log-level=LEVEL | INFO | Log level |
--no-console | off | Disable console logging |
Running It on Windows
The FTP daemon uses a stream_select() event loop and does not require
pcntl_fork for normal operation.
Run it in the foreground on Windows:
php scripts/ftp_daemon.php
--daemon is Unix-oriented and requires pcntl_fork.
Running as a Service
systemd
[Unit]
Description=BinktermPHP FTP Daemon
After=network.target
[Service]
Type=simple
User=yourusername
Group=yourusername
WorkingDirectory=/path/to/binktest
ExecStart=/usr/bin/php /path/to/binktest/scripts/ftp_daemon.php
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Then:
sudo systemctl daemon-reload
sudo systemctl enable binkterm-ftpd
sudo systemctl start binkterm-ftpd
Cron (@reboot)
@reboot /usr/bin/php /path/to/binktest/scripts/ftp_daemon.php --daemon
Virtual Filesystem
Authenticated Users
/qwk/download/<BBSID>.QWKgenerates the user's QWK packet on demand/qwk/upload/*.REPor/qwk/upload/*.ZIPimports REP replies/incoming/<AREA>/...uploads files to writable file areas/fileareas/...browses and downloads approved files
Anonymous Users
Anonymous login is disabled by default. To enable it, set in .env:
FTPD_ALLOW_ANONYMOUS=true
When enabled, anonymous login is only permitted on registered systems, using:
- username:
anonymousorftp - password: any string
Anonymous users are restricted to:
/fileareas/...only- file areas marked
is_public
Anonymous users cannot:
- access
/qwk/... - upload to
/incoming/... - upload REP packets
If the system is not registered, anonymous login is rejected regardless of
the FTPD_ALLOW_ANONYMOUS setting.
Port 21 Without Running as Root
Linux only allows root to bind directly to ports below 1024, including FTP
port 21.
Because of that, the recommended deployment is:
- run
scripts/ftp_daemon.phpas a normal user on2121 - redirect external port
21to internal port2121 - forward the passive range to the daemon unchanged
iptables Redirect Rules
Local Redirect on the Same Host
Use this when the machine itself receives traffic on port 21 and the FTP
daemon runs locally on 2121.
sudo iptables -t nat -A PREROUTING -p tcp --dport 21 -j REDIRECT --to-ports 2121
If clients on the same host connect to the machine's own public IP, you may also need:
sudo iptables -t nat -A OUTPUT -p tcp -d YOUR.SERVER.IP --dport 21 -j REDIRECT --to-ports 2121
Replace YOUR.SERVER.IP with the machine's actual IP.
Port Forward From a Router or Firewall
If the daemon sits behind a router/NAT device, configure these forwards:
- external TCP
21-> internal TCP2121 - external TCP
2122-2149-> internal TCP2122-2149
Then set:
FTPD_PUBLIC_HOST=your.public.hostname
or:
FTPD_PUBLIC_HOST=203.0.113.10
nftables Redirect Rules
Local Redirect on the Same Host
The equivalent nftables redirect rule looks like this:
sudo nft add table ip nat
sudo nft 'add chain ip nat prerouting { type nat hook prerouting priority dstnat; }'
sudo nft add rule ip nat prerouting tcp dport 21 redirect to :2121
If clients on the same host connect to the machine's own public IP, you may also need an output-chain redirect:
sudo nft 'add chain ip nat output { type nat hook output priority -100; }'
sudo nft add rule ip nat output ip daddr YOUR.SERVER.IP tcp dport 21 redirect to :2121
Replace YOUR.SERVER.IP with the machine's actual IP.
Port Forward From a Router or Firewall
If the daemon sits behind a router/NAT device, the forwarding requirements are
the same as with iptables:
- external TCP
21-> internal TCP2121 - external TCP
2122-2149-> internal TCP2122-2149
Persisting iptables
Rules added with iptables are not persistent by default.
Common persistence methods:
- Debian/Ubuntu:
iptables-persistent - RHEL/Alma/Rocky: firewalld rich/direct rules, or a saved ruleset restored at boot
- custom boot script or systemd unit
Example on Debian/Ubuntu:
sudo apt-get install iptables-persistent
sudo netfilter-persistent save
Persisting nftables
On systems that use nftables natively, save the running ruleset and ensure it
is loaded at boot.
Example:
sudo sh -c 'nft list ruleset > /etc/nftables.conf'
sudo systemctl enable nftables
sudo systemctl restart nftables
Firewall Checklist
Allow inbound TCP for:
21if you are exposing the standard FTP port externally2121if clients connect directly to the daemon without redirect2122-2149for passive transfers
If passive ports are not reachable, login may succeed but directory listings and file transfers will fail.
Logging
The daemon logs to:
data/logs/ftpd.log
Logged events include:
- user logins
- anonymous logins
- uploads
- downloads
- transfer byte counts
- success/failure status
Troubleshooting
Daemon exits immediately with "FTPD is disabled"
- Set
FTPD_ENABLED=truein.env
Login works but LIST or downloads fail
- verify the passive port range is forwarded and allowed through firewalls
- verify
FTPD_PUBLIC_HOSTmatches the address clients actually use
Works on LAN but not from the Internet
- check router/NAT port forwards
- check external firewall rules
- confirm passive ports are forwarded, not just the control port
Cannot bind to port 21
- expected when not running as
root - keep the daemon on
2121and use redirect/forwarding rules instead
Windows background mode fails
- run in foreground on Windows
--daemonrequires Unixpcntl_fork