GerardSmit.FtpServer.Sockets
0.1.6
dotnet add package GerardSmit.FtpServer.Sockets --version 0.1.6
NuGet\Install-Package GerardSmit.FtpServer.Sockets -Version 0.1.6
<PackageReference Include="GerardSmit.FtpServer.Sockets" Version="0.1.6" />
<PackageVersion Include="GerardSmit.FtpServer.Sockets" Version="0.1.6" />
<PackageReference Include="GerardSmit.FtpServer.Sockets" />
paket add GerardSmit.FtpServer.Sockets --version 0.1.6
#r "nuget: GerardSmit.FtpServer.Sockets, 0.1.6"
#:package GerardSmit.FtpServer.Sockets@0.1.6
#addin nuget:?package=GerardSmit.FtpServer.Sockets&version=0.1.6
#tool nuget:?package=GerardSmit.FtpServer.Sockets&version=0.1.6
FtpServer in .NET
FTP(S) server implementation in C# / .NET
Note: This project is still experimental. It is no longer unsafe by default, but it has not been through a security audit or a production deployment. Review the configuration before exposing it to an untrusted network.
Features
Server
- NativeAOT support (a ~6 MB self-contained executable)
- Linux and Windows support
- Custom path for the FTP root directory, confined with a sub-filesystem jail that also refuses
junctions and symbolic links leading out of it (set
FollowLinksto treat the root as a mapping and follow them; hard links are outside what either setting can control) - Username/password authentication, optional anonymous access, per-user home directories
- Virtual folders: any directory on the host, mounted anywhere in the tree, optionally read-only — server-wide or per user
- A management CLI (
FtpServer user add) that writes hashed accounts into the settings file, which the running server picks up without a restart - PBKDF2 password hashes (
PasswordHash); the plain and SHA-256 forms still work and warn at startup - Read-only mode, connection limits, idle timeouts and failed-login throttling
- Two hosting front-ends: raw sockets (default) and Kestrel
FTP
- FTPS, explicit (
AUTH TLS) and implicit (TLS from the first byte on a second port, conventionally 990), with optional TLS on the data connection and session resumption across the two (so clients that require it, such as FileZilla, are satisfied) MODE Zdeflate compression of the data connection- Active and passive mode, including IPv6 (
EPRT/EPSV) and the long forms (LPRT/LPSV) - UTF-8 pathnames (RFC 2640)
Supported commands
| Area | Commands |
|---|---|
| Access control | USER PASS ACCT REIN QUIT HOST |
| Security | AUTH PBSZ PROT |
| Navigation | CWD CDUP PWD MKD RMD RMDA DELE RNFR RNTO XCUP XMKD XPWD XRMD |
| Transfer setup | TYPE MODE (S, Z) STRU ALLO REST PORT EPRT LPRT PASV EPSV LPSV |
| Transfers | RETR STOR STOU APPE ABOR |
| Listings | LIST (incl. -R unless Features.LISTR is off, and a pathname) NLST MLSD MLST STAT |
| Metadata | SIZE MDTM MFMT MFCT (Windows, macOS) MFF AVBL DSIZ |
| Checksums | HASH RANG XCRC XMD5 XSHA1 XSHA256 XSHA512 |
| Informational | FEAT OPTS HELP SYST NOOP SITE (CHMOD/HELP/SYMLINK/UTIME) LANG CLNT CSID |
Deliberately answered 502: SMNT, SPSV, THMB, XRCP, XRSQ, XSEM, XSEN, MFCT on Linux
(which has no way to set a creation time),
and the RFC 2228 mechanisms that TLS does not use (ADAT, MIC, CONF, ENC). CCC answers
534 rather than dropping the control connection back to cleartext.
Exercised by an integration suite that drives the protocol at the byte level, and by two
independent client implementations in CI: curl (plain and explicit FTPS, including REST
resume and NLST) and System.Net.FtpWebRequest. FileZilla and WinSCP have not been tested;
the commands they rely on are covered individually, but nobody has driven a real session with them.
Beyond Compare 4 (Indy's TIdFTP) has been checked against the command sequence it sends and the
replies it accepts, again without a live session.
Client compatibility notes
Where the RFCs leave room, the server answers the way common clients expect:
XCRC/XMD5/XSHA*answer250 <HEX>, the bare upper-case digest and nothing after it. That is the de-facto form, and the only one Indy-based clients such as Beyond Compare accept.HASHkeeps its213 <algorithm> <range> <digest> <path>reply. When the whole argument names an existing file it is used as the pathname as-is, so names likea,b.txtorreport 2023are not mistaken for a path plus a byte range.RESTis accepted in eitherTYPE. The marker applies to the nextRETR/STOR/APPE; if that transfer is still inTYPE Awith a nonzero marker it is refused with554before any150, because an offset into the translated stream does not map onto the file. Clients that probe for resume support withREST 1while still in the defaultTYPE Asee350.- A resumed
STORtruncates the file at the end of the new data, so nothing stale is left behind it. A marker past the end of the file (or for a file that does not exist) is refused with554before the150. - MLSx facts default to
type;size;modify;perm;UNIX.mode.UNIX.ownerandUNIX.groupare supported but off until a client asks for them withOPTS MLST: they cost an account lookup, and clients that never sendOPTS MLSTwould pay for facts they do not parse. On Windowspermis derived from the session's access and the read-only attribute, not from the file's ACL. SITE CHMODon Windows answers200and changes nothing, saying so in the reply text. Clients that copy permissions on upload (Beyond Compare does by default) would otherwise report every upload as failed. SetFeatures.IgnoreUnsupportedChmodtofalsefor a504instead.SITE UTIMEaccepts all three forms clients send: ProFTPD's time-firstSITE UTIME <YYYYMMDDhhmm[ss]> <path>, the multi-timestampSITE UTIME <path> <atime> <mtime> <ctime> UTC(Pure-FTPd, WinSCP), and<path> <time>. Pathnames may contain spaces in every form.MDTM <time> <path>sets the modification time, the form servers used beforeMFMT, with Serv-U's optional+/-minuteszone suffix. A file whose name really begins with a timestamp is still read, never written.MFFis all or nothing: it setsmodify,create(Windows, macOS) andUNIX.mode(Unix), and a fact it cannot change refuses the whole command with504.MODE Zcompresses transfers and listings with zlib;OPTS MODE Z LEVEL <0-9>picks the level (default 6). Clients only use it whenFEATlists it and their user enables compression.Features.ModeZturns it off.SITE SYMLINK <target> <link>is off by default (Features.SiteSymlink). When on, the target is resolved like any pathname, must exist inside the same folder tree as the link, and the link stores a relative path, so it can never reach anything the session could not reach already. On Windows it needs Developer Mode or the symbolic-link privilege.- Implicit FTPS (
ImplicitTlsPort) protects the data connection by default, acceptsPROTwithoutPBSZ, answersAUTHwith503, and keeps TLS throughREIN. It is independent ofFtps, which governs explicitAUTH TLSonPort.
Configuration
Settings come from appsettings.json, then FTP_-prefixed environment variables, then the
command line — later sources win.
{
"Port": 21,
"Path": "ftproot", // never "/" — the server refuses to start
"Passive": true,
"Ftps": false,
"RequireTls": false, // reject USER/PASS until AUTH TLS has run
"ImplicitTlsPort": null, // e.g. 990: also serve implicit FTPS on this port
"ReadOnly": false,
"FollowLinks": false, // follow junctions/symlinks that lead outside Path
"VirtualFolders": [ // mounted for every session; users can add or override their own
{ "Path": "/public", "PhysicalPath": "D:/data/public", "ReadOnly": true }
],
"Authentication": {
"AllowAnonymous": false,
"AnonymousCanWrite": false,
"MaxFailedAttempts": 5,
"Users": [
{
"Name": "alice",
"PasswordHash": "pbkdf2-sha256$210000$<salt>$<hash>",
"HomeDirectory": "alice",
"VirtualFolders": [
{ "Path": "/shared", "PhysicalPath": "D:/data/shared" },
{ "Path": "/archive", "PhysicalPath": "D:/data/archive", "ReadOnly": true }
]
}
]
},
"Connection": {
"MaxConnections": 100,
"MaxConnectionsPerAddress": 10,
"MaxCommandLength": 4096,
"IdleTimeout": "00:05:00"
},
"DataConnection": {
"AllowForeignHost": false, // leave off: this is what blocks the FTP bounce attack
"MinimumPort": 1024,
"VerifyPeerAddress": true,
"PublicAddress": null, // public IP advertised in PASV replies when behind NAT
"Timeout": "00:00:30"
},
"Certificate": {
"Path": "server.pfx", // omit to generate a self-signed certificate
"Password": null,
"AllowTlsResume": true // leave on: clients may require the data connection
// to resume the control connection's TLS session
},
"Features": {
"XCRC": true, // XCRC/XMD5/XSHA1/XSHA256/XSHA512
"LISTR": true, // honour LIST -R; off ignores the flag
"MLST": true,
"Hash": true, // HASH
"Site": true, // SITE CHMOD/HELP/UTIME (and SYMLINK, below)
"IgnoreUnsupportedChmod": true, // Windows: SITE CHMOD answers 200 as a no-op, not 504
"ModeZ": true, // MODE Z deflate compression
"SiteSymlink": false // SITE SYMLINK, confined to the session's own tree
}
}
Virtual folders
VirtualFolders grafts directories onto a tree. Path is where the folder appears; PhysicalPath
is the directory it maps to, absolute or relative to the server root.
It can be set in two places. At the top level it applies to every session, anonymous ones
included; on a user it applies to that user alone, on top of the server-wide list. A user entry
that mounts the same Path as a server-wide one replaces it rather than colliding with it — which
is how one user is given their own /data, or a stricter one.
{
"VirtualFolders": [
{ "Path": "/public", "PhysicalPath": "D:/data/public", "ReadOnly": true }
],
"Authentication": {
"Users": [
{ "Name": "alice", "PasswordHash": "...", "VirtualFolders": [
{ "Path": "/public", "PhysicalPath": "D:/data/public" } // alice may write to it
] }
]
}
}
Unlike HomeDirectory, a virtual folder is not confined to the server root — mapping a
directory from elsewhere on the host is the point of it. Only the mapped subtree is reachable
through the mount, and links leading out of it are still refused unless FollowLinks is on (which
each folder can override for itself). A ReadOnly folder refuses every modifying command with 550,
whatever the user's own access is, and rename is refused at both ends.
With no VirtualFolders anywhere, a session is served exactly as before: the root, or the user's
home directory, and nothing else.
An authenticator can go further and hand a session a file system of its own — see
IFtpFileSystemSource and the FtpUser constructor. That is the extension point for a user store
that is not the settings file: the source is asked for a fresh IFileSystem per login, so it can
serve an in-memory tree, a composed set of mounts, or a remote store.
Managing users
The server executable doubles as its own management CLI. Passwords are prompted for, never taken as an argument, so they stay out of the shell history and the process list.
FtpServer user add alice --home alice
| Command | What it does |
|---|---|
user add <name> [--home <dir>] [--read-only] [--iterations N] |
Adds an account with a PBKDF2 hash |
user list |
Lists the configured accounts and how each password is stored |
user passwd <name> |
Replaces the password, dropping any weaker stored form |
user remove <name> |
Removes the account |
hash |
Prints a hash to paste in by hand |
All of them take --config <path> (default: appsettings.json beside the executable) and edit only
the Authentication:Users array, leaving every other setting — including ones this build does not
know about — untouched. Comments in the file are read, but a save rewrites the document and drops
them. Running with no command starts the server, so FtpServer --Port 2121 still works.
What a running server picks up
The settings files are watched. An account added, changed or removed while the server runs takes effect on the next login — no restart:
- Live: the user list, per-user homes, virtual folders, read-only flags, anonymous access, login throttling, listing limits, and the timeouts a new session reads.
- Restart only:
Port,PassivePortRange,Certificateand the hosting front-end. These are bound to sockets that are already open. - Sessions that are already logged in keep the tree and access they logged in with.
A saved file that is invalid, or that does not parse at all, is reported in the log and ignored: the server keeps running on the last configuration that was valid. At startup the same problem stops the server instead, because there is nothing to fall back on.
Security notes
- Set
Path. It is always mounted as a jail, and a value of/is rejected at startup. Every operation is checked against where the path really lands, so a junction or symbolic link inside the tree cannot be used to read or write outside it.FollowLinks: trueturns that off, for a root deliberately assembled out of links. Neither setting affects hard links, which have no target to refuse to follow — keep the root on a filesystem no untrusted account can write to. The root's own location is resolved at startup and re-checked at most once a second, rather than on every operation: if a directory at or above the root is swapped for a link, the server notices within that second and then refuses everything until the root lands where it did. - Store passwords as
PasswordHash.PasswordandPasswordSha256are kept for existing configurations and warn at startup: an unsalted digest can be cracked offline at billions of guesses a second if the configuration ever leaks.FtpServer user addalways writes the hashed form. - A virtual folder is a hole in the jail, by design.
PhysicalPathis not confined toPath, so mount only what the user should have, and preferReadOnlywhere writes are not needed. - Authentication is required by default. Set
Authentication.AllowAnonymousto open the server up, and pair it withReadOnlyorAnonymousCanWrite: falseunless uploads are wanted. DataConnection.AllowForeignHostshould stay off. Turning it on re-enables the RFC 2577 bounce attack, where the server can be used to reach hosts and ports the client cannot.- Configure a real certificate for FTPS. The generated self-signed one changes on every restart and no client can verify it.
- Behind NAT, set
DataConnection.PublicAddressto the router's public IP and forward the passive port range (plus port 21) to the server. Without it, PASV advertises the private LAN address, which internet clients cannot reach. Every peer but a loopback one gets it, LAN peers included (in a container the local address is the container's, which they cannot reach either), so it must be an address those reach too. EPSV is unaffected because its reply never carries an address. The server does not open router ports itself (no UPnP/NAT-PMP) — forwarding must be configured on the router.
Usage
- Go to the
src/FtpServerdirectory - Adjust
appsettings.json - Run:
dotnet run
Publish a self-contained executable:
dotnet publish -c Release -r win-x64
With a runtime identifier the server is published as a NativeAOT executable. Cross-compiling AOT
is not supported by .NET; add -p:PublishAot=false to publish without it.
Development
dotnet test
The suite covers the parsers and the listing formatter directly, and drives a real server over a loopback socket for the protocol-level behaviour.
TLS session resumption cannot be observed through SslStream, so it has its own check that uses
openssl s_client:
tools/verify-tls-resumption.sh 2500
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- GerardSmit.FtpServer.Core (>= 0.1.6)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.0)
- Pipelines.Sockets.Unofficial (>= 2.2.8)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.