ironport

package module
v1.0.73 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: Apache-2.0 Imports: 34 Imported by: 0

README

ironport

Go Report Card codecov Build Status

A production-ready, embeddable SFTP server and FTP server library for Go with a security-first design. The production-ready claim applies to the library API; the command under cmd/ironport-demo is only a runnable demo.

Features

  • SFTP and FTP/FTPS in one binary — a single server hosts both protocols against the same user database, jails, and permission flags. FTPS (explicit TLS via AUTH TLS, RFC 4217) opt-in with FtpTLSConfig; pair with FtpRequireTLS to refuse plaintext logins entirely
  • HTTP upload endpoint — HttpIngest() returns an http.HandlerFunc you mount on your own router (mux.HandleFunc("/upload", srv.HttpIngest())). It accepts a multipart/form-data POST with the file under the file field (key=file), authenticates with HTTP Basic auth using the same username/password as SFTP/FTP/FTPS, and stores the file in the user's jail — sharing the same permission flags, CompletedUploads, and AuthEvents streams. Put it behind TLS (your own http.Server/reverse proxy), since Basic credentials are otherwise sent in the clear
  • SSH public-key and password authentication — both methods use constant-time comparisons to prevent username enumeration via timing side-channels
  • Per-user jail (chroot) — each user is confined to a configurable root directory. Every client path is resolved via Linux openat2 with RESOLVE_IN_ROOT | RESOLVE_NO_SYMLINKS, so the kernel itself rejects path traversal and any symlink anywhere in the lookup
  • Fine-grained permissions — independent CanRead / CanWrite flags per user
  • Dynamic user management — add, remove, and update users and their authorized keys at runtime without restarting the server
  • Upload notifications — a buffered CompletedUploads() stream delivers a CompletedUpload struct (protocol — SFTP, FTP, or HTTP — username, full on-disk path, jail-relative path, and client IP) for every successfully closed upload
  • Auth notifications — a buffered AuthEvents() stream delivers LoginSuccess, LoginFailed, and Logout events for SFTP and FTP sessions; HTTP uploads emit LoginSuccess/LoginFailed per authenticated request
  • Temp-file aware completion — optionally set TempExtensions on the config (for example, .tmp, .writing) to suppress completion notifications for temporary upload names and emit the notification when the file is renamed to a non-temp name. Dotfiles (names beginning with .) are always treated as temporary in the same way
  • Graceful shutdown — Close() stops the listener immediately and lets in-flight sessions finish on their own. Shutdown(ctx) stops the listener AND waits for in-flight sessions to finish, force-closing any that remain when ctx expires
  • Thread-safe runtime APIs — user-management helpers and listener lifecycle methods are safe to call while the server is running
  • Resource limits — finite caps on connections, SSH channels, SFTP handles, passive FTP listeners, and concurrent HTTP requests, with absolute login deadlines and shared authentication throttling
  • Handshake timeout — connections that do not complete the SSH handshake within 30 seconds are dropped
  • SSH algorithm pinning — optionally constrain SSH key exchange, ciphers, MACs, and public-key auth signature algorithms
  • Idle-session timeout — configurable via IdleTimeout on the config (default 15 minutes); inactive authenticated SFTP sessions are reaped
  • Empty-password protection — users whose stored Password is empty cannot authenticate via password, and empty supplied passwords are always rejected
  • Chown opt-in — pathname Setstat requests that try to change file ownership (uid/gid) are rejected with a permission error unless SftpAllowChown is explicitly set to true on the config. Symlink creation by clients is always refused, while SFTP access/modification time changes (Chtimes) are applied through the same fd-relative jail policy.

For users with write permission, SFTP attribute updates through an open handle (FSETSTAT, including sftp.File.Truncate, Chmod, and Chown) return SSH_FX_OP_UNSUPPORTED. Read-only users retain the existing permission denial. The pinned pkg/sftp request server dispatches them using the original pathname, which could identify a different file after a rename or replacement. Pathname SETSTAT operations, such as sftp.Client.Truncate, Chmod, Chown, and Chtimes, remain supported subject to the user's write permission and ownership policy.

Platform support

This package is Linux-only. The path-containment guarantee depends on the openat2 syscall with RESOLVE_IN_ROOT | RESOLVE_NO_SYMLINKS, available since Linux 5.6. ListenAndServe probes for openat2 at startup and returns an error on older kernels rather than silently degrading the policy.

Existing files are pinned with O_PATH, checked to be regular files, and then reopened through an accessible, trusted /proc/self/fd for I/O. This rejects FIFOs, sockets, and devices before opening them for I/O while preserving normal file-lease waits. Truncation occurs only after a successful open. Chmod uses the same checks and also permits directories. These operations fail closed if required descriptor reopening is unavailable.

Configured UserInfo.Root paths must name existing directories at login. Symlinks in any root-path component and literal .. components are rejected; use the real directory path when migrating a symlinked configuration. Relative roots are resolved from a verified working-directory snapshot at authentication time, keeping the retained directory and its reporting path consistent.

srv.ValidateJailRoots() checks kernel support and logs warnings for configured roots that cannot be opened securely. It returns an error only if the kernel capability probe fails; invalid or missing user roots are advisory so directories can be provisioned later. ListenAndServe and HttpIngest() call it automatically, including for HTTP-only deployments. AddUser also checks its root after publishing the user update and disconnecting revoked sessions. These checks may block on filesystem or logger I/O, but do not hold the user lock or retain root handles. They do not verify permissions for later file operations.

Root-open failures after a successful credential check produce server-side explanations, sampled at most once per second per server with a suppressed count. Usernames, paths, and errors are escaped in diagnostics; authentication replies and events retain their generic failure reasons. These advisory checks do not replace authentication-time validation.

Authentication opens and retains the root directory for the SSH/FTP session or HTTP request, including every SFTP channel on that SSH connection. Renaming or replacing the configured path does not redirect an authenticated session. CompletedUpload.FullFilePath is a reporting path and may become stale after a root or parent directory is renamed.

Project policy

  • Security reports: see SECURITY.md.
  • Release notes: see CHANGELOG.md.
  • Compatibility: release versions follow SemVer. While the major version is v1, exported Go APIs are backward compatible across minor and patch releases; breaking API changes require a major version bump.
  • Contributions: see CONTRIBUTING.md.

Quick start

package main

import (
    "log"

    "github.com/define42/ironport"
)

func main() {
    users := map[string]ironport.UserInfo{
        "alice": {Password: "alicepw", Root: "/srv/sftp/alice", CanRead: true, CanWrite: true},
        "bob":   {Password: "bobpw",   Root: "/srv/sftp/bob",   CanRead: true, CanWrite: false},
    }

    // Load a stable host key from disk. If this is left unset, ListenAndServe
    // generates an ephemeral in-memory host key.
    signer, err := ironport.NewSignerFromFile("/etc/ssh/sftp_host_key")
    if err != nil {
        log.Printf("host key unavailable, using ephemeral key: %v", err)
    }

    // FtpAddr is "" by default, disabling the (plaintext) FTP listener.
    config := ironport.DefaultConfig()
    config.SftpAddr = ":2022"
    config.Users = users
    if signer != nil {
        config.SftpSigner = signer
    }
    srv := ironport.NewServer(config)

    // Drain upload notifications in the background.
    go func() {
        for ev := range srv.CompletedUploads() {
            log.Printf("upload complete: protocol=%q user=%q ip=%q path=%q full=%q",
                ev.Protocol, ev.Username, ev.ClientIP, ev.FilePath, ev.FullFilePath)
        }
    }()

    // Drain auth/session notifications in the background.
    go func() {
        for ev := range srv.AuthEvents() {
            log.Printf("auth event: type=%q protocol=%q user=%q ip=%q",
                ev.Type, ev.Protocol, ev.Username, ev.ClientIP)
        }
    }()

    log.Fatal(srv.ListenAndServe())
}
Configuring the upload-notification buffer size

The default capacity is 65,536 entries. Set CompletedUploadSize on the server config to override it:

config := ironport.DefaultConfig()
config.Users = users
config.SftpSigner = signer
config.CompletedUploadSize = 131_072
srv := ironport.NewServer(config)

Read upload notifications from the receive-only CompletedUploads() stream. The underlying channel is internal, so callers cannot send to it, close it, or replace it while the server is running. To change the buffer capacity, set a different CompletedUploadSize before calling NewServer.

Configuring the auth-notification buffer size

Set AuthEventSize on the server config:

config := ironport.DefaultConfig()
config.Users = users
config.SftpSigner = signer
config.AuthEventSize = 256
srv := ironport.NewServer(config)

Read authentication and logout notifications from the receive-only AuthEvents() stream. The underlying channel is internal and follows the same non-blocking, caller-drained behavior as CompletedUploads().

Deferring completion notifications until final rename

Many clients upload to a temporary filename first (for example file.txt.tmp or file.txt.writer) and rename to the final filename only after the upload is fully complete. Configure TempExtensions to emit CompletedUploads() events at that final rename boundary:

config := ironport.DefaultConfig()
config.Users = users
config.SftpSigner = signer
config.TempExtensions = []string{".tmp", ".writing", ".writer"}
srv := ironport.NewServer(config)

With this setting:

  • uploads that close with a temp extension are not announced yet
  • clients may set the modification time on the temp file with SFTP Chtimes
  • renaming from a temp extension to a non-temp name emits the completion event

In addition to configured temp extensions, files whose name begins with a dot (for example .in-progress or .foo.txt) are always treated as in-progress: their completion notification is deferred until the file is renamed to a non-dot, non-temp name. This applies even when TempExtensions is empty. Only renames whose destination is a regular file are announced, so renaming a hidden directory (for example .staging to staging) never emits a completion event.

Pinning SSH algorithms

Set SSH algorithm fields on the config before constructing the server to restrict SSH negotiation. Nil fields keep the defaults from golang.org/x/crypto/ssh; non-nil fields are used as allow-lists in the order supplied:

config := ironport.DefaultConfig()
config.Users = users
config.SftpSigner = signer
config.SSHKeyExchanges = []string{ssh.KeyExchangeCurve25519}
config.SSHCiphers = []string{ssh.CipherAES256CTR}
config.SSHMACs = []string{ssh.HMACSHA256}
config.SSHPublicKeyAuthAlgorithms = []string{
    ssh.KeyAlgoED25519,
    ssh.KeyAlgoRSASHA256,
}
srv := ironport.NewServer(config)

For RSA host-key signature pinning, pass a signer already restricted with ssh.NewSignerWithAlgorithms.

Resource and authentication limits

Limits belong to one Server and are shared by its protocol handlers. Configure Config.ResourceLimits (ResourceLimitConfig) and Config.AuthLimits (AuthLimitConfig) before calling NewServer; zero or negative fields select finite defaults, including when constructing Config directly. For example:

config := ironport.DefaultConfig()
config.ResourceLimits.MaxConnections = 256
config.ResourceLimits.SFTPMaxHandlesPerConnection = 16
config.ResourceLimits.HTTPMaxConcurrentRequests = 8
config.AuthLimits.MaxEntries = 4096
if err := config.Validate(); err != nil {
    return err
}
srv := ironport.NewServer(config)

The following fields belong to Config.ResourceLimits:

Configuration Default Scope
MaxConnections / MaxConnectionsPerIP 128 / 32 SFTP and FTP control connections combined, including unauthenticated clients
SSHMaxChannels 16 Simultaneous session channels per SSH connection
SFTPMaxHandles / SFTPMaxHandlesPerUser / SFTPMaxHandlesPerConnection 256 / 64 / 32 Open files and directories; the connection budget spans every channel on one SSH connection
FTPMaxPassiveListeners / FTPMaxPassiveListenersPerUser / FTPMaxPassiveListenersPerIP 64 / 2 / 8 Outstanding passive-port reservations, including data-connection setup
HTTPMaxConcurrentRequests 32 Requests executing across every HttpIngest handler created from this server
HTTPMaxAuthorizationBytes 4,096 Raw Authorization headers, before Basic-auth decoding
HTTPMaxUsernameBytes / HTTPMaxPasswordBytes 256 / 1,024 Decoded HTTP Basic-auth credentials
FTPAuthTimeout 30 seconds Absolute login window; anonymous NOOP, USER, and REIN cannot extend it
FTPMaxAuthFailures 5 Failed credential checks over the lifetime of one FTP control connection

Connection overload closes new sockets before starting a handler. SSH channel overload rejects only the new channel. SFTP handle admission happens before opening, creating, or truncating a file; closing a handle or tearing down its channel returns its permit. Passive FTP exhaustion returns 425. Each passive reservation expires from the time of PASV/EPSV, normally after 30 seconds, and releases on replacement, logout, disconnect, cancellation, or completed data setup. Established FTP transfers no longer occupy passive-listener slots.

Directory listings use bounded server memory: SFTP reads entries on demand from an open directory cursor, and FTP LIST, MLSD, and STAT stream their output. SFTP directory descriptors and handle permits remain held until the client closes the handle or the session ends, including after end-of-directory. Listings reflect filesystem changes during enumeration rather than a stable snapshot. FTP STAT uses the complete-reply write deadline; a failure after its multiline reply starts closes the control session.

SSH has an absolute 30-second handshake deadline in addition to its read idle timeout. FTP successful login stops its login timer; logging out starts a new window. FTPMaxAuthFailures is not reset by USER, REIN, or a successful login.

AuthLimits configures token buckets shared by SSH, FTP, and HTTP. Defaults allow bursts of 200 attempts server-wide, 30 per source, and 20 per username, refilling one token every 10 ms, one second, and one second respectively. A source is one IPv4 address or one IPv6 /64 prefix. IPv4-mapped IPv6 addresses use the IPv4 budget. Every supplied username consumes the same budgets, whether or not the account currently exists. Adding or removing an account does not change its username budget or bypass an existing cooldown. Successful credential checks consume tokens too. Each admitted SSH key eligibility check consumes one token per bucket; an accepted check and its first signature proof share that admission. Verified completion rechecks current credentials and cooldown. Rejected key eligibility checks do not advance credential-failure backoff, allowing clients to offer several keys before the authorized one. They still emit LoginFailed events with reason AuthFailureCredentials. Unsigned key queries check key eligibility without opening the jail or emitting root diagnostics. The jail is opened and retained only after signature verification, using the user's current root, keys, and permissions. A repeated proof after a failed completion requires fresh admission. Every applicable bucket must permit an attempt before any tokens are consumed. Admission retains its original budgets through verification, including when users are dynamically added, changed, or removed.

Three failed credential checks start a 250 ms cooldown, doubling after each further failure up to 30 seconds. Attempts during cooldown are rejected immediately. HTTP returns 429 with Retry-After, FTP returns 421 and closes the control connection, and SSH returns an authentication error. Success clears username failure state only when all retained failures in the bucket belong to that username. Unsigned key queries and successful logins for colliding usernames do not change failure ownership. Source penalties and username penalties shared by failures for different usernames are not cleared by success. Instead, each bucket forgets one failure for every maximum-backoff period (30 seconds by default) without another failure, even while it stays in use. A finished attack's penalties therefore fade within about five minutes with the default backoff settings. A steady attacker still holds maximum backoff, and pausing to let failures decay or a slot expire cannot raise the attempt rate. Failure logs and throttled failure events are sampled. AuthEvent.Reason distinguishes AuthFailureCredentials from AuthFailureThrottled on LoginFailed events; success and logout events leave it empty. Throttling events may be sampled even when the event queue has room, so this stream is not a complete attempt count. MaxEntries bounds the number of buckets: the default 8,192 slots are split between 4,096 sources and 4,096 usernames. A private, randomized hash assigns each identity a stable slot. Collisions conservatively share tokens and backoff; smaller limits increase collateral throttling. Storage capacity itself never rejects new identities, and identity churn cannot evict active penalties. Idle slots become eligible for reset after 15 minutes, once their tokens have fully refilled, active cooldowns have ended, and every failure has decayed. Longer refill intervals and a short EntryTTL therefore cannot shorten limits or backoff across idle periods. Addresses come from the transport peer, never forwarded headers, so clients behind one proxy share its source budget.

Config.Validate() rejects a positive AuthLimits.MaxEntries below two and contradictory positive BackoffInitial/BackoffMax settings. If only the maximum is set below 250 ms, the default initial delay is reduced to honor it. If only the initial delay exceeds 30 seconds, the default maximum is raised to match. DefaultConfig() fills in positive defaults: when lowering its maximum below 250 ms, also lower or zero its initial delay. For example, set config.AuthLimits = ironport.AuthLimitConfig{BackoffMax: 100 * time.Millisecond} to use a 100 ms maximum with other authentication defaults. NewServer keeps its existing signature; invalid settings make ListenAndServe return an error and HTTP ingest return 503 before authentication or body reads. Validation is captured at construction, so later changes to the config do not change a running server. Listener/TLS/kernel checks still run at startup.

Finite defaults introduce admission rejection for applications that previously accepted unlimited work. Negative FtpDataAcceptTimeout now disables only active FTP dial deadlines; passive reservations still expire after 30 seconds.

These are application admission limits, not OS file-descriptor or storage quotas. MaxConnections excludes HTTP connections and FTP data sockets. HTTP callers own the listener and must configure read-header, read, write, and idle timeouts plus MaxHeaderBytes; the demo provides an example. HTTP request slots remain occupied until the entire request body, including any multipart epilogue, has been consumed or rejected. A stalled body therefore holds a slot until the caller's read timeout or the client disconnects. Middleware must preserve HTTP transport controls as described below. Multiple Server instances and processes have separate budgets.

FTP support (opt-in)

This package also exposes an FTP listener that shares the SFTP user database, jails, and permission flags. The listener is disabled by default — set FtpAddr to enable it. Without FtpTLSConfig, FTP transmits credentials and data in the clear, so it should only be used on a trusted network segment where you control all clients and intermediate hops. To require encryption, set FtpTLSConfig and FtpRequireTLS = true (see FTPS support below).

config := ironport.DefaultConfig()
config.Users = users
config.SftpSigner = signer
config.FtpAddr = ":2121"
config.FtpPassivePortRange = "5000-5010"
config.FtpPassiveAdvertisedIP = "203.0.113.7" // advertise this IP in PASV (for NAT); empty uses the local IP
config.FtpDataAcceptTimeout = 30 * time.Second // zero selects this default
config.FtpAllowActiveMode = false              // opt in to PORT/EPRT when needed
srv := ironport.NewServer(config)

When FTP is enabled, passive mode (PASV / EPSV) is supported by default. Active mode (PORT / EPRT) is refused unless FtpAllowActiveMode is true. Even then, the server only dials back to the same IP as the control connection to prevent FTP bounce behavior. Passive data connections are checked against the control connection IP to prevent passive-port stealing. A rejected IP or FTPS handshake leaves the passive endpoint available for another peer, up to 32 rejected connections within the original reservation deadline. Passive reservations start at PASV/EPSV and active dials wait up to FtpDataAcceptTimeout; retries do not extend that deadline. A negative value disables only the active dial deadline; passive reservations still expire after 30 seconds. Behind NAT or a port-forward, set FtpPassiveAdvertisedIP to the external IPv4 address so the PASV reply advertises an address clients can reach instead of the server's internal address.

Each complete FTP control reply, including multiline replies, has a five-minute write deadline. A failed control write closes the session and cancels its data operations.

For STOR and APPE, the server sends 150 and validates the data connection, including FTPS session binding, before creating or truncating the destination. Failed data setup leaves existing files unchanged and creates no new file. Destination-open errors (550) and invalid restart offsets (554) are reported after 150 and successful data setup; the data connection is then closed and no upload-completion event is emitted.

FTP and FTPS clients can read modification times with MDTM or the modify fact in MLST/MLSD, and can set file modification times with MFMT YYYYMMDDHHMMSS path.

FTPS support (RFC 4217, explicit TLS)

Set FtpTLSConfig to a *tls.Config that carries the server certificate to advertise AUTH TLS, PBSZ, and PROT over the FTP listener. Set FtpRequireTLS = true to require AUTH TLS before USER/PASS and PBSZ followed by PROT P before any data transfer. This is the recommended mode for any FTP listener that faces an untrusted network.

cert, err := tls.LoadX509KeyPair("ftps.crt", "ftps.key")
if err != nil {
    log.Fatal(err)
}
config := ironport.DefaultConfig()
config.Users = users
config.SftpSigner = signer
config.FtpAddr = ":2121"
config.FtpTLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
config.FtpRequireTLS = true
srv := ironport.NewServer(config)

WrapSession and UnwrapSession must remain unset: custom ticket handling could bypass the control-session binding, so the server rejects such FTPS configs at startup when FTP is enabled. An enabled FTP listener also requires FtpTLSConfig when FtpRequireTLS is true; otherwise ListenAndServe fails before opening any listener. These FTP TLS checks are skipped when FtpAddr is empty or whitespace-only.

A successful AUTH TLS clears the plaintext login, data endpoint, and EPSV ALL/PBSZ/PROT state. Clients must log in again and send PBSZ 0 followed by PROT P to protect data transfers. Diagnostics for rejected buffered commands record only a recognized command verb and byte count; arguments and unknown command tokens are omitted.

The implementation covers the three FTPS implementation traps that are easy to get wrong:

  • Buffered-bytes injection — after replying 234 to AUTH TLS, the server verifies the receive buffer is empty before starting the TLS handshake. A man-in-the-middle on the plaintext segment cannot pipeline attacker-controlled commands behind the legitimate AUTH TLS line.
  • Data-channel binding — every AUTH TLS control connection receives a private session-ticket key shared only with its data-connection config. PROT P also enforces DidResume == true, so a ticket obtained from a different control connection is rejected even when both clients share one public IP. A peer that hijacks the data port cannot mount a fresh handshake or reuse a ticket from another session.
  • Clean close_notify on transfers — protected STOR/APPE uploads require the client to send TLS close_notify. A bare transport close returns 426 and emits no CompletedUpload, even when an ALLO byte count matches; partial data remains on disk. Every TLS-wrapped data connection is half-closed with CloseWrite before the underlying socket is closed, so the client sees a proper TLS EOF and can distinguish a complete download from a truncated one.

Only explicit FTPS is supported; implicit FTPS (port 990, TLS from byte zero) is intentionally not implemented. The CCC command is refused — once TLS is negotiated, the session stays encrypted.

HTTP upload endpoint (opt-in)

In addition to SFTP and FTP, the server can accept uploads over HTTP. Unlike the SFTP/FTP listeners, the HTTP endpoint does not run its own listener: HttpIngest() returns an http.HandlerFunc that you mount on your own *http.ServeMux (or any router), so the upload URL lives inside your existing HTTP application and you control the TLS termination, middleware, and routing.

srv := ironport.NewServer(config)

mux := http.NewServeMux()
mux.HandleFunc("/upload", srv.HttpIngest())

// Terminate TLS yourself — HTTP Basic credentials are otherwise in the clear.
server := &http.Server{
    Addr: ":8443", Handler: ironport.WithHTTPControl(mux),
    ReadHeaderTimeout: 10 * time.Second,
    ReadTimeout: 30 * time.Minute, WriteTimeout: 30 * time.Minute,
    IdleTimeout: time.Minute, MaxHeaderBytes: 16 << 10,
}
log.Fatal(server.ListenAndServeTLS("server.crt", "server.key"))

When adding middleware, keep WithHTTPControl outermost:

server.Handler = ironport.WithHTTPControl(middleware(mux))

It captures the original response controller in the request context so rejected slow bodies can be interrupted even when middleware hides optional writer interfaces. Middleware must preserve that context. The wrapper is optional if all response-writer wrappers implement Unwrap() http.ResponseWriter and middleware preserves the complete request body. Middleware that replaces or limits the body needs the outer wrapper so any unread original body is consumed before an upload releases its concurrency slot.

Without either mechanism, middleware that forwards http.Hijacker can still have rejected HTTP/1 connections closed, but the error response is lost. If middleware hides both deadline and hijacking support, the library logs a sampled integration error; cleanup then depends on the server's read timeout. Install WithHTTPControl outside that middleware to retain bounded cleanup and normal HTTP error responses. HTTP/2 rejection affects only the request stream.

The endpoint accepts one or more files per request:

  • Method — POST only; anything else is answered with 405 and an Allow: POST header.
  • Authentication — HTTP Basic auth. The username and password are the same credentials used for SFTP/FTP/FTPS, validated with the same constant-time password comparison. A missing or wrong credential returns 401 with a WWW-Authenticate challenge. Throttled authentication returns 429 with Retry-After. Oversized raw credentials return 431; oversized decoded credentials return 401. Because Basic auth sends the credential on every request, run this endpoint over TLS (your own http.Server or a TLS-terminating reverse proxy).
  • Permission — the authenticated user must have CanWrite, or the request is rejected with 403.
  • Body — multipart/form-data carrying one or more parts under the form field file (i.e. key=file). Each part's filename becomes the destination path, relative to the user's jail root. A nested name such as reports/2026/q2.csv (or /reports/2026/q2.csv) is stored at that path, creating any missing parent directories. The path is resolved through the same openat2 no-symlink jail as SFTP/FTP, so .. segments are collapsed and contained and a crafted filename (for example ../../etc/passwd) can neither escape the jail nor follow a symlink.
  • Multiple files — repeating the file field (a standard <input type="file" name="file" multiple> form, or curl -F file=@a -F file=@b) stores every part. Each file is written independently and announced separately, and one file's failure does not abort the others.
  • Success — each file is stored (creating or overwriting) and streamed straight to disk (not buffered whole in memory or staged in a temp directory), and a CompletedUpload with Protocol == "HTTP" is announced per file on CompletedUploads(). The handler replies 201 Created when at least one file was stored and none failed.
  • Partial/total failure — if any file fails, the reply is an error status (400 for client errors such as an invalid filename or no file part, 500 for a server-side write error) with a plain-text body listing each file's outcome (stored <path> / failed <path> (<reason>)), so a partial failure is never silent. Files that did store are kept.

TempExtensions and dotfile handling apply exactly as they do for SFTP/FTP: an upload whose name ends in a configured temp extension, or begins with a dot, is written but its completion is deferred. Because a single HTTP request has no later rename step, such a deferred upload is only announced if the file is later renamed to a final name over SFTP/FTP.

A quick upload with curl:

# Stored at the jail root as report.csv.
curl -u alice:alicepw -F file=@./report.csv https://host:8443/upload

# Stored at <jail>/reports/2026/q2.csv; the reports/2026 directories are created.
curl -u alice:alicepw -F 'file=@./q2.csv;filename=reports/2026/q2.csv' https://host:8443/upload

# Several files in one request — each part is stored and announced separately.
curl -u alice:alicepw -F file=@./a.csv -F file=@./b.csv https://host:8443/upload

The handler admits at most ResourceLimits.HTTPMaxConcurrentRequests requests at once across all handlers created from the same server. Saturation returns 503 with Retry-After: 1 before authentication or body reads. Early rejection stops request reads and disables HTTP/1 connection reuse through the preserved transport controls described above, so slow bodies cannot keep occupying resources after admission capacity has been returned.

The handler imposes no upload-size limit of its own (matching SFTP/FTP); wrap it with http.MaxBytesReader, or bound the request body at your reverse proxy, if you need a cap.

Public-key authentication

Add one or more public keys to a user's AuthorizedKeys field at construction time, or use the AddUserKey / RemoveUserKey helpers at runtime:

// At construction.
users["alice"] = ironport.UserInfo{
    Root:           "/srv/sftp/alice",
    CanRead:        true,
    CanWrite:       true,
    AuthorizedKeys: []ssh.PublicKey{alicePubKey},
}

// At runtime (safe to call while the server is running).
srv.AddUserKey("alice", newKey)
srv.RemoveUserKey("alice", oldKey)

AddUserKey preserves established sessions. Removing an existing key with RemoveUserKey closes that user's SFTP/FTP sessions, including FTP data operations. Removing a missing key leaves sessions connected.

Dynamic user management

// Add or replace a user.
srv.AddUser("carol", ironport.UserInfo{
    Password: "carolpw",
    Root:     "/srv/sftp/carol",
    CanRead:  true,
    CanWrite: true,
})

// Remove a user and terminate their active SFTP/FTP sessions.
srv.RemoveUser("carol")

// Remove all users without deleting any on-disk user data.
srv.RemoveAllUsers()

User changes are checked against pending logins: removing a user, replacing credentials, or changing the root or permissions prevents an old authentication snapshot from starting a new session, even if the same user is removed and added again. An effective AddUser replacement also closes the user's existing SFTP/FTP sessions. Equivalent replacements preserve them; authorized-key order, duplicates, and nil entries do not count as credential changes.

Use AddUserKey to add a key without disconnecting established sessions. The addition still invalidates pending authentication snapshots so login commits use the current configuration. HTTP authentication is per request: updates reject stale authentication, while uploads already authorized continue. HTTP request cancellation and server shutdown remain owned by the caller.

Graceful shutdown

Shutdown(ctx) stops the listeners so no new connections are accepted, then waits for every in-flight handler to return. If ctx expires first, the remaining tracked connections are force-closed and ctx.Err() is returned. After Shutdown returns, ListenAndServe will have returned nil; the same Server can be started again by calling ListenAndServe.

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
    log.Printf("shutdown: %v", err)
}

Use Close() when you want the legacy behavior of closing the listener without waiting for sessions to drain. The same Server can be started again after Close returns; existing sessions from the previous run continue until they finish or a later Shutdown(ctx) drains them.

Host key

Use NewSignerFromFile to load a PEM-encoded RSA, ECDSA, or Ed25519 private key:

signer, err := ironport.NewSignerFromFile("/etc/ssh/sftp_host_key")

If config.SftpSigner is nil, ListenAndServe generates an ephemeral in-memory RSA-3072 host key and stores it on the server. This is convenient for demos, but not suitable for production because clients will see a different host key after each process restart.

Running the demo binary

go run ./cmd/ironport-demo -host-key /path/to/host_key

cmd/ironport-demo is intentionally not an operator tool. It hard-codes example users, uses basic logging, has no metrics, config file, readiness/healthcheck endpoint, or service-manager integration, and is meant to show library wiring rather than production deployment.

If -host-key is omitted, ListenAndServe generates a fresh RSA-3072 key on every start. This is not suitable for production, as clients will see a different host key each time. The demo binary also accepts comma-separated -ssh-key-exchanges, -ssh-ciphers, -ssh-macs, and -ssh-public-key-auth-algorithms flags.

License

See LICENSE for details.

Documentation

Overview

Package ironport provides an embeddable, security-hardened SFTP and FTP server.

Core features:

  • Per-user jail roots enforced via path resolution and symlink checks.
  • Password and SSH public-key authentication with constant-time comparisons.
  • Fine-grained CanRead / CanWrite per-user permission flags.
  • Runtime user management (AddUser, RemoveUser, RemoveAllUsers, AddUserKey, RemoveUserKey).
  • Optional SSH algorithm pinning for key exchange, ciphers, MACs, and public-key auth signatures.
  • Graceful shutdown via Close; upload-completion notifications via CompletedUploads; authentication/session notifications via AuthEvents.
  • Optional FTP listener sharing the same users, jails, permissions, temp-extension handling, CompletedUploads stream, and AuthEvents stream as SFTP. FTP uses passive mode by default; active mode can be enabled explicitly.
  • Optional FTPS (RFC 4217 explicit TLS via AUTH TLS) over the FTP listener. Set Config.FtpTLSConfig to enable it and Config.FtpRequireTLS to require protected login and data channels.

Typical usage:

cfg := ironport.DefaultConfig()
cfg.SftpAddr = ":2022"
cfg.FtpAddr = ":2121"
cfg.Users = users
cfg.SftpSigner = signer
srv := ironport.NewServer(cfg)
log.Fatal(srv.ListenAndServe())

Index

Constants

View Source
const (
	// CompletedUploadProtocolSFTP identifies an upload completed through SFTP.
	CompletedUploadProtocolSFTP = "SFTP"
	// CompletedUploadProtocolFTP identifies an upload completed through FTP.
	CompletedUploadProtocolFTP = "FTP"
)
View Source
const (

	// CompletedUploadProtocolHTTP identifies an upload completed through the
	// HTTP ingest endpoint (HttpIngest).
	CompletedUploadProtocolHTTP = "HTTP"
)

Variables

This section is empty.

Functions

func NewSignerFromFile

func NewSignerFromFile(path string) (ssh.Signer, error)

NewSignerFromFile reads a PEM-encoded private key from the given file path and returns an ssh.Signer suitable for use as a server host key. It supports any key type accepted by ssh.ParsePrivateKey (RSA, ECDSA, Ed25519).

func WithHTTPControl added in v1.0.53

func WithHTTPControl(next http.Handler) http.Handler

WithHTTPControl preserves the HTTP transport controls needed to terminate rejected upload bodies, even when downstream middleware hides optional ResponseWriter interfaces. It also keeps original-body cleanup under the upload admission limit when middleware replaces or limits the body. Install it outside the entire middleware chain:

server := &http.Server{Handler: ironport.WithHTTPControl(middleware(mux))}

Middleware must preserve the request context. Nested calls retain the outermost controller, which is invalidated when this wrapper returns so late handlers cannot affect a reused connection. This changes neither routing nor admission limits. It is optional when middleware preserves the complete request body and every ResponseWriter wrapper implements Unwrap() http.ResponseWriter, as required by http.ResponseController.

Types

type AuthEvent added in v0.1.7

type AuthEvent struct {
	// Type is the authentication event kind.
	Type AuthEventType
	// Reason distinguishes credential rejection from authentication throttling
	// for AuthEventLoginFailed. It is empty for successful login and logout.
	Reason AuthFailureReason
	// Username is the username supplied by the client for this event.
	Username string
	// ClientIP is the remote IP address of the client, without the port. It is
	// empty if the address could not be parsed.
	ClientIP string
	// Protocol is the file-transfer protocol used for the event.
	// It is CompletedUploadProtocolSFTP, CompletedUploadProtocolFTP, or CompletedUploadProtocolHTTP.
	Protocol string
}

AuthEvent describes an authentication or session-lifecycle event. It is the payload delivered on the server's AuthEvents stream.

type AuthEventType added in v0.1.7

type AuthEventType string

AuthEventType identifies the kind of authentication event delivered on the AuthEvents channel (login success, login failure, or logout).

const (
	// AuthEventLoginSuccess identifies a successful user login.
	AuthEventLoginSuccess AuthEventType = "LoginSuccess"
	// AuthEventLoginFailed identifies a rejected user login attempt.
	AuthEventLoginFailed AuthEventType = "LoginFailed"
	// AuthEventLogout identifies the end of an authenticated session.
	AuthEventLogout AuthEventType = "Logout"
)

type AuthFailureReason added in v1.0.53

type AuthFailureReason string

AuthFailureReason identifies why a login attempt was rejected. It is empty for successful login and logout events.

const (
	// AuthFailureCredentials identifies a login rejected during credential validation.
	// It includes SSH key eligibility rejections, which do not advance backoff.
	AuthFailureCredentials AuthFailureReason = "Credentials"
	// AuthFailureThrottled identifies a login rejected by authentication limits.
	AuthFailureThrottled AuthFailureReason = "Throttled"
)

type AuthLimitConfig added in v1.0.53

type AuthLimitConfig struct {
	// GlobalBurst and GlobalInterval default to 200 attempts and one token per
	// 10 ms. IPBurst/IPInterval default to 30 and one per second; UserBurst/
	// UserInterval default to 20 and one per second.
	GlobalBurst    int
	GlobalInterval time.Duration
	IPBurst        int
	IPInterval     time.Duration
	UserBurst      int
	UserInterval   time.Duration
	// MaxEntries bounds source and username buckets (default 8192).
	// A positive value must be at least two. Half the slots serve sources and
	// the remainder serve usernames. Private hashing assigns stable slots;
	// collisions share budgets and backoff rather than evicting active state
	// or rejecting all new identities when storage fills. Smaller limits
	// increase collateral throttling. EntryTTL is the minimum idle time before
	// resetting a slot, defaults to 15 minutes, and is never shorter than the
	// maximum backoff. Reset also requires full tokens, elapsed cooldown, and
	// fully decayed failures, so a short EntryTTL cannot restart backoff early.
	MaxEntries int
	EntryTTL   time.Duration
	// Backoff starts after three failed verifications and doubles after each
	// further failure. Defaults are 250 ms initial and 30 seconds maximum.
	// SSH key eligibility rejections consume tokens but do not advance backoff.
	// When BackoffInitial is unset, it is capped by BackoffMax. Explicit positive
	// values with BackoffInitial greater than BackoffMax fail Config.Validate.
	// An unset maximum is raised to an explicit initial value above 30 seconds.
	// Backoff rejects attempts immediately; it never sleeps in a handler.
	// Successful authentication clears a username slot's failure history only
	// when all retained failures belong to that username. Unsigned key queries
	// do not change failure ownership. Source and mixed-username failure state
	// is not cleared by success; each slot forgets one failure per BackoffMax
	// without another failure, so a finished attack's penalties fade while the
	// slot stays active. Steady failures still hold maximum backoff for any
	// EntryTTL.
	BackoffInitial time.Duration
	BackoffMax     time.Duration
}

AuthLimitConfig bounds authentication work across SSH, FTP, and HTTP. All non-positive fields select secure defaults; limits cannot be disabled. Limits apply to successful and failed authentication attempts. Source budgets use individual IPv4 addresses and IPv6 /64 prefixes. Addresses come only from the transport peer; forwarded headers are never trusted. Every supplied username consumes the same budgets, whether or not the account currently exists.

type CompletedUpload

type CompletedUpload struct {
	// Username is the authenticated SFTP, FTP, or HTTP user that performed the upload.
	Username string
	// FullFilePath is the absolute path of the uploaded file on the server's
	// local filesystem, constructed from the root path at authentication. This
	// reporting path may become stale if the root or a parent directory is moved;
	// filesystem operations remain pinned to the originally opened directory.
	FullFilePath string
	// FilePath is the file path as seen by the client, relative to the
	// user's jail root (e.g. "/incoming/foo.txt").
	FilePath string
	// ClientIP is the remote IP address of the client that performed
	// the upload, without the port. It is empty if the address could not
	// be parsed.
	ClientIP string
	// Protocol is the file-transfer protocol used for the upload.
	// It is CompletedUploadProtocolSFTP, CompletedUploadProtocolFTP, or CompletedUploadProtocolHTTP.
	Protocol string
}

CompletedUpload describes a file upload that has finished successfully. It is the payload delivered on the server's CompletedUploads stream.

type Config added in v0.1.17

type Config struct {
	SftpAddr            string
	FtpAddr             string
	FtpPassivePortRange string
	// FtpDataAcceptTimeout bounds how long passive-mode FTP data listeners wait
	// for the client data connection after PASV/EPSV, and how long active-mode
	// FTP dials may take after PORT/EPRT. Zero selects the package default
	// (30 seconds); negative disables active-mode dial deadlines, but passive
	// reservations still expire after 30 seconds.
	FtpDataAcceptTimeout time.Duration
	// FtpAllowActiveMode enables FTP active mode (PORT/EPRT). It defaults to
	// false because active mode requires outbound connections from the server.
	// When enabled, this server only dials the same IP as the control connection.
	FtpAllowActiveMode bool
	// FtpTLSConfig, when non-nil, enables explicit FTPS (RFC 4217). The
	// AUTH TLS command upgrades the control connection using this config,
	// and PROT P wraps the data connection. A private config pair and ticket
	// key are derived for each control session so data connections can only
	// resume that control session. Callers should populate Certificates (or a
	// GetCertificate callback) but otherwise leave session-ticket settings at
	// their defaults. Custom WrapSession and UnwrapSession callbacks are not
	// supported because they can bypass the control-session binding. Leave nil
	// to disable FTPS.
	FtpTLSConfig *tls.Config
	// FtpRequireTLS, when true, refuses USER/PASS over the FTP control
	// connection until AUTH TLS has succeeded and refuses data-transfer
	// commands until the client has negotiated PBSZ followed by PROT P.
	// When FTP is enabled, ListenAndServe requires FtpTLSConfig to be set and
	// fails before opening listeners if it is nil. Ignored when FTP is disabled.
	FtpRequireTLS bool
	// FtpPassiveAdvertisedIP optionally overrides the IPv4 address sent to
	// clients in the PASV (227) reply. By default the control connection's
	// local IP is advertised, which is wrong behind NAT or a port-forward:
	// the address the server sees on its own socket is the internal one,
	// not the public address the client must dial. Set this to the
	// external/advertised IPv4 address in such deployments. It only affects
	// the PASV reply; EPSV does not carry an address. Leave empty to
	// advertise the control connection's local IP.
	FtpPassiveAdvertisedIP string
	Users                  map[string]UserInfo
	SftpSigner             ssh.Signer
	// CompletedUploadSize sets the CompletedUploads channel capacity.
	// Non-positive values select the default of 65,536.
	CompletedUploadSize int
	// AuthEventSize sets the AuthEvents channel capacity.
	// Non-positive values select the default of 64.
	AuthEventSize int
	// SSHKeyExchanges, SSHCiphers, SSHMACs, and
	// SSHPublicKeyAuthAlgorithms optionally pin SSH negotiation and public-key
	// auth signature algorithms. Nil slices use golang.org/x/crypto/ssh
	// defaults.
	SSHKeyExchanges            []string
	SSHCiphers                 []string
	SSHMACs                    []string
	SSHPublicKeyAuthAlgorithms []string
	// ResourceLimits bounds connections, channels, handles, listeners, and HTTP
	// requests. Non-positive fields select finite defaults.
	ResourceLimits ResourceLimitConfig
	// AuthLimits bounds authentication attempts and retry backoff jointly
	// across SSH, FTP, and HTTP. Zero fields select secure defaults.
	AuthLimits AuthLimitConfig
	// TempExtensions marks file extensions that should defer completion
	// notifications until a later rename to a non-temp name. Matching is
	// case-insensitive.
	TempExtensions []string
	// IdleTimeout bounds authenticated SFTP connection inactivity and FTP
	// control-session inactivity between commands. Zero selects the package
	// default; negative disables the idle timeout.
	IdleTimeout time.Duration
	// TCPKeepAlivePeriod controls the SO_KEEPALIVE idle period applied to
	// accepted SFTP and FTP control connections. Zero selects the package
	// default (30 seconds); a negative value disables keepalive entirely.
	// Probes keep idle control connections alive through stateful
	// firewalls/NAT and surface half-open peers as read errors.
	TCPKeepAlivePeriod time.Duration
	// SftpAllowChown controls whether SFTP clients may change file ownership
	// inside their jail through pathname SETSTAT. It defaults to false.
	// Attribute updates through an open handle (FSETSTAT) are unsupported.
	SftpAllowChown bool
}

Config holds the values used to construct a server.

func DefaultConfig added in v0.1.17

func DefaultConfig() *Config

DefaultConfig returns a fresh server configuration with package defaults applied. Callers should set Users before starting the server. Set SftpSigner to use a stable host key; when SftpSigner is nil, ListenAndServe generates an ephemeral in-memory host key.

func (*Config) Validate added in v1.0.53

func (c *Config) Validate() error

Validate checks authentication-limit bounds before constructing a server. Nil and zero-valued configurations use defaults and are valid. Non-positive limit settings select defaults; MaxEntries must be at least two when set, and an explicit BackoffInitial must not exceed an explicit BackoffMax. Listener addresses, TLS settings, and kernel support are checked separately when starting listeners. NewServer retains validation failures: ListenAndServe returns the error and HttpIngest rejects requests with HTTP 503.

type ResourceLimitConfig added in v1.0.53

type ResourceLimitConfig struct {
	// SSHMaxChannels limits simultaneous session channels per SSH connection.
	// Non-positive values select the default of 16.
	SSHMaxChannels int
	// MaxConnections and MaxConnectionsPerIP bound accepted SFTP and FTP
	// control connections together, including unauthenticated connections.
	// Non-positive values select 128 and 32 respectively.
	MaxConnections      int
	MaxConnectionsPerIP int
	// FTPAuthTimeout is the absolute time allowed to authenticate an FTP
	// control session. Commands such as NOOP do not extend it. Non-positive
	// values select 30 seconds. Logging out starts a new authentication window.
	FTPAuthTimeout time.Duration
	// SFTPMaxHandles, SFTPMaxHandlesPerUser, and SFTPMaxHandlesPerConnection
	// bound open files and directories globally, per username (across SSH
	// connections), and per SSH connection (across channels). Non-positive
	// values select 256, 64, and 32. CLOSE and channel teardown release slots.
	SFTPMaxHandles              int
	SFTPMaxHandlesPerUser       int
	SFTPMaxHandlesPerConnection int
	// HTTPMaxConcurrentRequests caps requests executing in HttpIngest across
	// every handler created from this Server. Non-positive values select 32.
	// Requests over the limit are rejected immediately with 503 before their
	// Authorization header or body is processed.
	HTTPMaxConcurrentRequests int
	// HTTPMaxAuthorizationBytes limits the raw Authorization header before
	// Basic authentication decoding. Non-positive values select 4096 bytes.
	HTTPMaxAuthorizationBytes int
	// HTTPMaxUsernameBytes and HTTPMaxPasswordBytes limit decoded Basic-auth
	// credentials. Non-positive values select 256 and 1024 bytes respectively.
	HTTPMaxUsernameBytes int
	HTTPMaxPasswordBytes int
	// FTPMaxPassiveListeners caps outstanding PASV/EPSV listeners server-wide.
	// Zero or negative selects the default of 64.
	FTPMaxPassiveListeners int
	// FTPMaxPassiveListenersPerUser caps outstanding PASV/EPSV listeners for
	// one authenticated username. Zero or negative selects the default of 2.
	FTPMaxPassiveListenersPerUser int
	// FTPMaxPassiveListenersPerIP caps outstanding PASV/EPSV listeners for one
	// control-connection source IP. Zero or negative selects the default of 8.
	FTPMaxPassiveListenersPerIP int
	// FTPMaxAuthFailures closes a control session after this many failed
	// password attempts. Non-positive values select five; USER/REIN and
	// successful login do not reset it.
	FTPMaxAuthFailures int
}

ResourceLimitConfig bounds resources shared by one Server. Non-positive values select finite defaults; these limits cannot be disabled. Configure limits before NewServer, which copies the settings.

type Server added in v0.1.18

type Server struct {
	// contains filtered or unexported fields
}

Server is a self-contained SFTP server with optional FTP support.

func NewServer

func NewServer(config *Config) *Server

NewServer creates a new Server from config. Call Config.Validate to check admission settings first. Invalid settings make ListenAndServe return an error and HttpIngest reject requests with HTTP 503. Pass FtpAddr as "" to disable FTP. Leave FtpPassivePortRange empty to use OS-assigned passive data ports.

CompletedUploadSize sets the buffer capacity of the CompletedUploads channel. A non-positive value falls back to the package default (65,536). AuthEventSize sets the buffer capacity of the AuthEvents channel. A non-positive value falls back to the package default (64):

cfg := ironport.DefaultConfig()
cfg.Users = users
cfg.SftpSigner = signer
cfg.CompletedUploadSize = 131_072
cfg.AuthEventSize = 256
srv := ironport.NewServer(cfg)

func (*Server) AddUser added in v0.1.18

func (s *Server) AddUser(username string, info UserInfo)

AddUser adds or replaces a user entry in the server's user map. An effective change to credentials, root, or permissions invalidates pending logins and closes the user's established SFTP/FTP sessions. Equivalent replacements preserve sessions; authorized keys are compared as sets of wire-format values, ignoring order, duplicates, and nil entries. After publishing the update and closing revoked sessions, it checks the root and logs a warning if it cannot be opened. Invalid roots do not prevent user registration. This diagnostic may block on filesystem or logger I/O. It is safe to call concurrently with active connections.

func (*Server) AddUserKey added in v0.1.18

func (s *Server) AddUserKey(username string, key ssh.PublicKey)

AddUserKey appends key to the AuthorizedKeys of an existing user. If the key is already present (by wire-format equality) it is not added again. It is a no-op when username does not exist or key is nil. Adding a key does not revoke authority held by an existing session, so active sessions are left connected. The revision still changes to linearize the new authentication configuration with logins already in flight. It is safe to call concurrently with active connections.

func (*Server) AuthEvents added in v0.1.18

func (s *Server) AuthEvents() <-chan AuthEvent

AuthEvents returns a receive-only stream of authentication/session notifications. Each AuthEvent includes the protocol that produced it. The channel is buffered; sends are non-blocking so a slow consumer never stalls authentication or logout handling. Callers should drain the stream continuously. A full channel drops events. Throttled login failures are sampled even when the channel has capacity, so this stream is not a complete audit trail or an exact count of rejected attempts. Credential failures, successful logins, and logouts are not sampled.

The buffer capacity is set by Config.AuthEventSize. A non-positive value falls back to the package default (64).

func (*Server) Close added in v0.1.18

func (s *Server) Close() error

Close stops both listeners, causing ListenAndServe to return nil. It is safe to call concurrently with active connections; in-flight connections are not terminated, and the server can be started again after Close returns. Calling Close before ListenAndServe has been called, or after it has already returned, is a no-op.

func (*Server) CompletedUploads added in v0.1.18

func (s *Server) CompletedUploads() <-chan CompletedUpload

CompletedUploads returns a receive-only stream of successful upload notifications. Each CompletedUpload includes the protocol that produced it. The channel is buffered; sends are non-blocking so a slow consumer never stalls an upload. Callers should drain the stream continuously.

The buffer capacity is set by Config.CompletedUploadSize. A non-positive value falls back to the package default (65,536).

func (*Server) FTPListeningAddr added in v0.1.18

func (s *Server) FTPListeningAddr() net.Addr

FTPListeningAddr returns the actual FTP network address the server is listening on, or nil if FTP is disabled or not currently listening.

func (*Server) HttpIngest added in v1.0.33

func (s *Server) HttpIngest() http.HandlerFunc

HttpIngest returns an http.HandlerFunc that accepts a file upload over HTTP and stores it in the authenticated user's jail, exactly as an SFTP or FTP STOR would. The handler is meant to be registered by the caller on their own HTTP server, so the endpoint URL is chosen by the application:

srv := ironport.NewServer(config)
mux := http.NewServeMux()
mux.HandleFunc("/upload", srv.HttpIngest())

Request contract:

  • Method must be POST; anything else gets 405 with an Allow header.
  • HTTP Basic authentication is required. The username and password are the same credentials used for SFTP/FTP/FTPS and are checked with the same constant-time password comparison. A missing or invalid credential gets 401 with a WWW-Authenticate challenge; only an attempt that supplied a wrong credential (not a bare unauthenticated probe) emits a LoginFailed AuthEvent. Oversized raw Authorization headers get 431 before decoding; oversized decoded credentials get 401 without logging or events. Throttled attempts get 429 with Retry-After.
  • HTTPMaxConcurrentRequests bounds in-flight work across every handler created from this Server; saturation gets 503 with Retry-After.
  • The authenticated user must have CanWrite; otherwise the request gets 403.
  • The body must be multipart/form-data carrying one or more parts under the form field "file" (key=file). Each part's filename becomes the destination path, relative to the user's jail root. A nested name such as "reports/2026/q2.csv" (or "/reports/2026/q2.csv") is stored at that path and any missing parent directories are created. The path is resolved through the same openat2, no-symlink jail used by SFTP/FTP, so ".." segments are collapsed and contained, and a crafted name can neither traverse out of the jail nor follow a symlink.

Multiple "file" parts in one request are each stored independently (a standard multi-file form, e.g. curl -F file=@a -F file=@b), and each stored file is announced separately on the CompletedUploads stream (Protocol CompletedUploadProtocolHTTP) — honouring TempExtensions and dotfile deferral the same way SFTP/FTP do, so a ".tmp"/dotfile upload is intentionally not announced until a later rename. The handler replies 201 Created when at least one file was stored and none failed; if any file failed it replies with an error status (400 for client errors such as an invalid filename or no "file" part, 500 for a server-side write error) and a plain-text body listing each file's outcome, so a partial failure is never silent.

Like the other protocols, the handler never blocks on the notification streams: a slow CompletedUploads/AuthEvents consumer drops events rather than stalling an upload. The handler streams the body straight into the destination file without buffering the whole upload in memory or staging it in a temp directory. It imposes no size limit of its own (matching SFTP/FTP); callers that want one can wrap the handler with http.MaxBytesReader or bound the body at a reverse proxy.

Middleware must preserve ResponseController access via Unwrap, or callers must install WithHTTPControl outside the complete middleware chain. This allows early rejections to stop body reads before returning request capacity. Middleware that replaces or limits the request body also needs WithHTTPControl so the original body is consumed before successful uploads return capacity. Without transport control, HTTP/1 cleanup depends on the caller's read timeout.

Creating the handler checks kernel support and warns about unusable jail roots, including in HTTP-only deployments. A failed capability check makes requests return 503 until a later probe succeeds; root warnings remain advisory.

func (*Server) ListenAndServe added in v0.1.18

func (s *Server) ListenAndServe() error

ListenAndServe starts the SFTP server and, when configured, the FTP server too. It blocks until Close or Shutdown is called or an unexpected listener error occurs. It returns nil when stopped via Close or Shutdown.

A stopped server can be started again by calling ListenAndServe after Close or Shutdown returns. Concurrent ListenAndServe calls are rejected.

func (*Server) ListeningAddr added in v0.1.18

func (s *Server) ListeningAddr() net.Addr

ListeningAddr returns the actual SFTP network address the server is listening on, or nil if the SFTP listener is not currently listening. It is useful when the server was started with port 0 (OS-assigned port).

func (*Server) RemoveAllUsers added in v0.1.18

func (s *Server) RemoveAllUsers()

RemoveAllUsers removes every user and closes their authenticated SFTP/FTP connections. Pending logins cannot commit their old authentication snapshots, even if the same users are added again. Already-authorized HTTP requests continue, and no on-disk user data is removed. It is safe to call concurrently with active connections.

func (*Server) RemoveUser added in v0.1.18

func (s *Server) RemoveUser(username string)

RemoveUser removes a user and closes their authenticated SFTP/FTP connections, including FTP data operations. Pending logins using the old user are rejected. HTTP requests already authorized continue; subsequent authentication fails. It is safe to call concurrently with active connections.

func (*Server) RemoveUserKey added in v0.1.18

func (s *Server) RemoveUserKey(username string, key ssh.PublicKey)

RemoveUserKey removes key from the AuthorizedKeys of an existing user. It is a no-op when username does not exist, the key is not found, or key is nil. Removing an effective key disconnects that user's established SFTP/FTP sessions so a compromised credential cannot retain access through an old session. No-op removals leave existing sessions untouched. It is safe to call concurrently with active connections.

func (*Server) Shutdown added in v0.1.18

func (s *Server) Shutdown(ctx context.Context) error

Shutdown gracefully stops the server. It closes the listeners so no new connections are accepted, then waits for every in-flight handler goroutine to return.

If ctx is canceled or its deadline passes before all handlers finish, Shutdown forcibly closes every remaining tracked connection (which causes each handler's network I/O to fail and the handler to return), waits for those handlers to exit, and returns ctx.Err(). Passing context.Background() blocks until all handlers exit.

Shutdown is safe to call concurrently with Close and with itself. After Shutdown returns, ListenAndServe (if it was running) will have returned nil, and the server can be started again. Calling Shutdown before ListenAndServe has been started, or after it has already returned and all handlers have exited, returns immediately with nil.

func (*Server) ValidateJailRoots added in v1.0.63

func (s *Server) ValidateJailRoots() error

ValidateJailRoots checks kernel support and logs warnings for configured roots that cannot currently be opened securely. Only a capability-probe failure is returned as an error. Unusable roots are advisory: applications may provision directories after registering users, but authentication fails until the root can be opened. It does not check file permissions for later I/O operations.

ListenAndServe and HttpIngest call this automatically before serving requests. It is safe to call concurrently with user changes and active connections. Checks and logging run outside the user lock and may block on filesystem or logger I/O. Diagnostic handles are closed immediately; authentication still opens and retains its own root, so validation does not guarantee a later login.

type UserInfo

type UserInfo struct {
	Password       string
	AuthorizedKeys []ssh.PublicKey // public keys allowed for SFTP authentication; nil or empty means public-key auth is disabled for this user
	Root           string          // jail root on disk, e.g. /srv/sftp/alice
	CanRead        bool            // allow read/download/list operations
	CanWrite       bool            // allow write/upload/delete/rename operations
}

UserInfo holds the credentials and jail root for a single SFTP/FTP/HTTP user. Root must name an existing directory when authenticating. Symlinks in any configured root component and literal ".." components are rejected. Relative roots are resolved from the working directory at authentication time.

Directories

Path Synopsis
cmd
ironport-demo command
Command ironport-demo is a runnable library demo for the ironport package.
Command ironport-demo is a runnable library demo for the ironport package.
internal
authlimit
Package authlimit bounds authentication work with shared token buckets and backoff.
Package authlimit bounds authentication work with shared token buckets and backoff.
jailfs
Package jailfs provides a jailed filesystem backend built on Linux openat2.
Package jailfs provides a jailed filesystem backend built on Linux openat2.
netconn
Package netconn provides connection deadlines, keepalive, and address helpers.
Package netconn provides connection deadlines, keepalive, and address helpers.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL
⌂ 换网址