Repository navigation
Releases: o-kolomoiets/Go-SFTP-Server
Release list
v0.4.0
[0.4.0] - 2026-10-17
Multi-user operation: reload on SIGHUP, users in users.d, SSH
certificates, host key rotation, Prometheus metrics, hooks, PROXY protocol,
a systemd unit and Landlock on Linux. It also fixes a crash that an
authenticated client could cause (see Fixed). Read
docs/operations.md before upgrading; five
changes need attention: headers from load balancers not in
server.trusted_proxies are refused; serve refuses trusted files inside
mounts and two host keys of one type; on Linux Landlock is on by default,
so some changes need a restart instead of a reload; and cert-authority
and command="internal-sftp" lines in authorized_keys, which v0.3
skipped with a warning, now take effect.
When upgrading a server behind a load balancer that sends PROXY protocol
headers, add the load balancer to server.trusted_proxies together with
the new binary (v0.3 refuses the key), or turn PROXY protocol off on the
load balancer before the upgrade: headers from any other address are now
refused (see Changed).
Upgrading
- A systemd unit with its own
SystemCallFilter=must allow the three
system calls of Landlock:landlock_create_ruleset,landlock_add_rule
andlandlock_restrict_self(@system-servicehas none of them, and
@sandboxexists only from systemd 254). Without them gosftpd runs
without Landlock and logslandlock: not availablewith the reason
"blocked by a seccomp filter" (withSystemCallErrorNumber=) or "killed
by a seccomp filter" (a filter that kills, such as the one of the
interim unit of the v0.3 hardening guide);server.startrecords
landlock=blockedin both cases. The shipped unit allows them; to
move to it from the interim unit, see
docs/install.md. - Check the
authorized_keysfiles forcert-authoritylines and lines
withcommand="internal-sftp"(or a path tosftp-server). v0.3
skipped them with a warning; v0.4 uses them, so acert-authorityline
lets in the certificates its CA signs for the user (see Changed). Remove
the lines that should not grant logins before upgrading.
Added
- Reload on SIGHUP (
systemctl reload): the audit log file is reopened
first (for logrotate), then the configuration file is read and checked
again and applied to new logins: users, keys, passwords, mounts,
permissions, limits, bans settings and the log level. Open connections
keep the configuration they logged in under; with the new
reload.disconnect_removed_usersthey are closed when the new
configuration would refuse their login. An invalid file keeps the running
configuration. A key file that is missing or unsafe on reload revokes its
keys instead of failing the reload, and a mount that is unavailable is
left out until the next reload. A remounted mount directory is opened
anew for new logins, withmount reopenedin the log (under Landlock:
after a restart, see Changed). See
docs/configuration.md
and ADR 0005. - Audit event
server.reload;conn.closeresultrevoked. sd_notify:READY=1,RELOADING=1withMONOTONIC_USEC,STOPPING=1
(Type=notify-reload).user add --writewritesusers.d/NAME.tomlafter checking the
configuration with the new user, and prints the connection details for
the user (host, port, host key fingerprint,sftpcommand).user disable,enableandremovechange a user's file without touching the
rest of it; the main configuration file is never rewritten.gosftpd init
includesusers.d/*.toml.auth.failurehas areasonwhen the login was refused although the key
offered is one of the user's or the password is right:disabled,
expired,address,key_expiredorremoved. The client is told
nothing more, and the key of such an account is refused already when
offered, as before. A key is checked again once the client has proved
that it holds it (VerifiedPublicKeyCallback), against the configuration
current then.user add --writeprints the warnings about the new user and refuses one
that could not log in (for example a password whileauth.methodshas
onlypublickey) unless--force.- OpenSSH user certificates (ADR 0007):
CAs trusted for every user (auth.trusted_user_ca_keys,
trusted_user_ca_keys_file), withusers.NAME.principalsto map
certificate principals to users, andcert-authoritylines with
principals=inauthorized_keys. Certificates without principals, with
SHA-1 CA signatures or with critical options other thansource-address
and an SFTP-onlyforce-commandare refused. See
docs/configuration.md. - Revoked keys and certificates (
auth.revoked_keys,revoked_keys_file):
a certificate revokes its key, as in sshd. The list fails closed: a line
that is not a key, or a KRL, is an error at start and fails a reload. auth.successnames a certificate login (cert_key_id,cert_serialas
a decimal string,cert_ca_fp;key_fpis the certified key's);
auth.failuregetskey_fpand these fields with a reason, and the
reasonskey_revoked,cert_principal,cert_expired,
cert_not_yet_validandcert_invalid.user addtakes--principal; with--writeand trusted CAs a user
needs no key, and the connection details show thessh-keygencommand to
sign its certificate.user listshows the principals.command="internal-sftp"(or a path tosftp-server, without arguments)
is accepted inauthorized_keys, since gosftpd serves only SFTP.- Host key rotation without breaking clients
(ADR 0008): after login, gosftpd
announces its host keys to OpenSSH clients, with a next keyKEY.next
and a previous keyKEY.old(hostkeys-00@openssh.com), and proves that
it holds them (hostkeys-prove-00@openssh.com), so that OpenSSH 8.5 and
later add the next key toknown_hostsbefore it is used and drop retired
keys.gosftpd hostkey rotatecreates the next key;--finish,
--rollback,--retireand--aborttake the further steps. New setting
server.announce_host_keys(on by default); new audit event
conn.hostkeys_proved. See
docs/configuration.md. - Host certificates: with
server.host_certificates = true,KEY-cert.pub
is served with each host key while it is valid, after the checks OpenSSH
applies; warnings before expiry. Go clients that pin the plain host key
(rclone) then needhost_key_algorithms, see
Host certificates. hostkey show --configshows every configured host key with its next and
previous keys and certificates; the start banner anduser add --write
show the next key. During a rotation, or with a certificate,hostkey showprints more lines after those of the current key: a script that
takes its last line should take the third.- Admin listener (ADR 0009):
[metrics] listenserves/metrics(Prometheus text format, written by
gosftpd itself, without a new dependency),/healthzand/readyz
(starting,stopping,audit_unavailable). Metrics count connections
by result (including health probes and scanners, which the audit log
leaves out), refusals, logins, authentication attempts, SFTP operations,
bytes, transfer times, conflicts, bans, reloads and audit write errors,
with labels from fixed sets only.metrics.pprofserves/debug/pprofin
binaries built with-tags pprof. See docs/metrics.md. gosftpd healthcheckfor container health checks: without--urlit
waits for the SSH banner ofserver.listenand leaves no trace in the
audit log; with--urlit checks the admin listener. It exits with 0 or
1 only.server.starthasmetrics_listenwith an admin listener.- Hooks (ADR 0010, docs/hooks.md):
[[hooks]]run a program (exec, argv without a shell,GOSFTPD_*
variables, the event on stdin) or call a webhook (the audit line, signed
with HMAC-SHA256 over the delivery ID, timestamp and body, with retries)
when a file event succeeds, filtered byon,mounts,globand
exclude, optionally after adelay. Asynchronous, with a bounded queue
per hook; a reload updates hooks in place, for open connections too. An
exec hook runs only if its file is still the one of the event, and its
program still belongs to root or gosftpd's user; its process group is
killed when it ends or times out. New metrics
gosftpd_hook_deliveries_total,gosftpd_hook_retries_total,
gosftpd_hook_queue_lengthandgosftpd_hooks_not_updated. - Audit: every
fs.*event on a path in a mount hasmount;fs.rename
has"uploaded":truefor the first rename by its uploader, within an
hour, of a file whose upload closed ok: the rename that completes a
temp-file upload (WinSCP's.filepart, rclone's.partial), or a plain
upload renamed later.fs.uploadhooks fire for it. - PROXY protocol v1 and v2 behind a load balancer
(ADR 0011): a connection from
an address in the newserver.trusted_proxiesmust begin with a PROXY
protocol header, which names the client. Bans,max_connections_per_ip,
allow_from,from=, certificatesource-addressand the audit log's
remote_addrandlocal_addrthen see the client, and the new context
fieldproxy_addrnames the load balancer.LOCALandUNKNOWNheaders
(health checks, clients of a Unix socket front end) are the loa...
v0.3.0
Hardening for servers exposed to the internet. Read
docs/security/hardening.md before upgrading a
public server; two changes need attention: serve refuses to run as root
without --allow-root, and uploads stop below 1 GiB of free space unless
min_free_space says otherwise.
Added
- Connection limits checked right after accept, before the SSH handshake:
limits.max_connections(256),max_connections_per_ip(16, per IPv4
address or IPv6 /64) andmax_preauth_connections(64). Refused
connections are audited asconn.reject, at most 10 per second. - Bans (
[auth.ban]): a source with 10 failures within 10 minutes is
refused at accept for 30 minutes, and its open connections get no further
password checks. Every wrong password is a failure at once; rejected keys
count once per connection, not per offered key; loopback is exempt by
default; the table is bounded. New audit eventauth.ban. server.idle_timeout(15m) closes connections without SFTP traffic;
server.keepalive_interval(30s) closes connections that leave 3
keepalive@openssh.comrequests unanswered.conn.closereports
idle_timeoutorkeepalive_timeout.- Opt-in password login:
auth.methods = ["publickey", "password"]and
password_hash(argon2id; bcrypt accepted for imported accounts). Each
attempt checks one hash; unknown users are checked against a stand-in of
the costliest configured hash, and failures wait until it would have
finished, so the response time does not reveal which users exist (fully
with one kind of hash;config validatewarns about mixed ones). Checks
run at most one per available CPU. A method missing fromauth.methodsis
refused, and its keys or passwords are not loaded.gosftpd user hash-password [--stdin]prints a hash, and
user add --password-hashadds it to a user. server.crypto_policy:modern(default, as before) orcompat, which
adds NIST curve and SHA-2 Diffie-Hellman key exchange and non-ETM MACs for
old clients.on_conflict = "version": an upload or posix-rename onto an existing file
replaces it and moves the old file to
.versions/<path>/<stem>.<UTC time><ext>, keepingversions.keep(10)
versions per file for at mostversions.max_age(30 days); only users who
may delete or overwrite files make older versions drop out. The versions
directory is not listed (sync tools would delete it); clients can open it
by path and read it, but not change it. Replacing a file this way
needs only thewritepermission.fs.uploadandfs.renameget
conflict = "versioned"andversion_path.atomic_uploads: uploads are written to a hidden temporary file
(.gosftpd-*.part) and take their name only when closed, by the conflict
policy at that moment; an aborted upload leaves nothing. Resume is not
possible then.fsyncflushes such uploads before they are published.
Temporary files left by a crash are removed once 24 hours old (checked at
start and every 6 hours) and when their directory is removed.max_file_size(off by default), checked against the end of every write
and on truncation, andmin_free_space(1 GiB), checked when a file is
opened for writing. Sizes accept units such as"10GiB".- Fuzzing: six targets (path resolution, confinement on a tree with
symlinks, conflict copy names, the configuration,authorized_keys, and
arbitrary SFTP packets against the real handlers) run 60 seconds each on
every change and 10 minutes each night. - CI checks coverage floors of the security packages (unit and interop
coverage merged), auditscrypto_policy = "modern"with ssh-audit, and
runs OpenSSF Scorecard weekly; all actions are pinned by commit SHA. - Documents: threat model,
hardening guide, a manual WinSCP checklist,
and ADR 0004 on
limits@openssh.com.
Changed
- BREAKING:
gosftpd serverefuses to run as root unless--allow-root
is given. - A connection keeps the user name of its first authentication request, as
sshd does; attempts with another name are refused. auth.successhaskey_fponly for public-key logins.- Uploads are refused while the mount's filesystem has less than 1 GiB free
(min_free_space;"0"turns the check off). fs.renamefor a posix-rename that replaced its target under
on_conflict = "overwrite"reportsconflict = "overwritten"instead of
"none".- Names starting with
.gosftpd-(in any case) are reserved: hidden from
listings and refused in paths. - BREAKING:
rename_templatemust contain{n}exactly once and be
valid UTF-8 of at most 64 bytes, so that copy names always fit 255 bytes;
config validatenames a template that does not.
Fixed
- A client that disconnected right after logging in left no
auth.success
andconn.closeevents. - A client that sent requests with a guessed handle while an OPEN was in
progress raced with the OPEN insidepkg/sftp, which could crash the
server; gosftpd now passes an OPEN on only after the earlier requests are
answered, and requests with a handle only after the OPEN is (found by
fuzzing). - Copy names under
on_conflict = "rename"could exceed 255 bytes for
names with a very long extension; a shortened name could equal the
original. - A configuration file whose first line ended inside an escape sequence
(a = "C:\) madeserveandconfig validatepanic instead of
reporting the error (found by fuzzing).
v0.2.0
Added
- Configuration file (TOML,
config_version = 1): found via--config,
$GOSFTPD_CONFIG,./gosftpd.toml,/etc/gosftpd/config.tomlor the user
configuration directory. Unknown keys are an error; every problem is
reported at once with its key path.includemerges[users.NAME]tables
from other files. - Multiple users (
[users.NAME]): inlineauthorized_keysand
authorized_keys_file,allow_from,expires,disabled. - Per-mount permissions: presets
read,upload,readwrite,fullor
flags (list,read,write,overwrite,delete,rename,mkdir,
rmdir,setstat). Uploaders may rename and set times on files they
created in the same session (WinSCP.filepart, rclone.partial). - Per-user home directories: a mount path ending in
{user}. A home that is
a symlink or not a directory makes the mount unavailable (audited as
fs.deniedwithreason=home_not_dir). - Mount options:
create,read_only,on_conflict,rename_template,
max_rename_attempts,compound_extensions,setstat_mode,umask,
require_mountpoint;[defaults]for all mounts andflatten. - Server options: several
listenaddresses,host_keyswith
host_key_auto_generate,handshake_timeout,shutdown_timeout;
[limits]max_sessions_per_conn,max_open_handles,max_auth_tries. - Commands:
init,config validate [--check-fs],config show,
config example [--full],user add,user list,hostkey generate,
completion. - Startup checks like sshd's StrictModes: configuration,
authorized_keys
and host key files must not be writable by group or others and must
belong to root or to the user running gosftpd. - Resumable uploads (OpenSSH
reput, paramiko modea, Cyberduck) with an
append-only guard: the bytes a file had when it was opened cannot be
changed or truncated (resume = "append-only", the default, or"off").
Withon_conflict = "overwrite"and theoverwritepermission a resume
withoutAPPENDis a plain write; withAPPENDthe old end is always
kept (offsets are used as sent, so a client that appends at offset 0 gets
"existing data is immutable").fs.uploadrecordsstart_offset; a
refused write is audited asfs.deniedand the upload asdenied.
After a renamedput, areputof the same name continues the copy. - A file that an upload has open cannot be opened for writing in place by
another upload (overwrite or resume) until it is closed: "file is busy". stat_redirect(default on): after an upload or rename went to a new name
under the rename policy, STAT, LSTAT and SETSTAT of the requested name by
the same user answer for the new one for 60 seconds, in all of the user's
connections, so paramikoput(confirm=True)and rclone's size check work
and never touch the original.- Uploaders may rename and set times on files they created with only the
writepermission in any of their connections (rclone uploads on one
connection and moves the file on another), for an hour after their last
change and only while nobody else changed the file. statvfs@openssh.com(dfin sftp) on Linux, macOS and FreeBSD; mounts
the user cannot change are reported read-only.- Listings show a virtual owner (uid/gid 1000, the user's own name in
ls -l) instead of host accounts. symlinks = "deny"refuses paths through existing symlinks and hides them
from listings; the default"inside-only"follows links that stay inside
the mount.[audit]eventsselects categories (conn,auth,session,
transfer,modify,denied, opt-inlistandstat;serveris
always on) andon_error = "fail-open"keeps serving when the audit log
cannot be written. The format is pinned by a schema test.- Release binaries for Linux and macOS (amd64, arm64) with
checksums.txt,
built by GoReleaser when a release is published. - Interop tests with OpenSSH 10.6p1, paramiko 5.0.0, rclone v1.75.0 and lftp
in CI. - Documentation:
docs/quickstart.md,configuration.md(every key),
audit-log.md,security.md,interop.md,release-checklist.md.
Changed
- New files are created with mode
0666 &^ umaskand directories with
0777 &^ umask; the defaultumaskis0027(was a fixed 0644/0755). servewithout--dirnow reads a configuration file instead of failing.
--authorized-keys,--userand--state-dironly apply with--dir.
v0.1.0-alpha
First alpha: gosftpd serve works with OpenSSH sftp and scp.
- public-key auth only; authorized_keys with from= / expiry-time=
- every mount confined with os.Root (no escapes via .., absolute paths, symlinks)
- uploads never silently overwrite: --on-conflict rename|reject|overwrite
- JSON audit log, fail-closed (no uploads or new connections if it can't be written)
- shell, exec and port forwarding refused
Install: go install github.com/o-kolomoiets/go-sftp-server/cmd/gosftpd@v0.1.0-alpha
Not yet: config file, multiple users, resumable uploads (planned for v0.2).