Skip to content
5 changes: 5 additions & 0 deletions cmd/fsend/internet.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ func runReceiveOverInternet(ctx context.Context, f *flags, c string, cfg *config
if allocErr != nil {
return fmt.Errorf("%w: %v", fserrors.ErrConnectFailed, allocErr)
}
if alloc.BudgetExhausted {
return fserrors.ErrRelayBudgetExhausted
}
if alloc.ForwardingDisabled {
return fmt.Errorf("%w: direct connection failed and this server has relay forwarding disabled", fserrors.ErrConnectFailed)
}
Expand Down Expand Up @@ -159,6 +162,8 @@ func classifyRelayDrop(ctx context.Context, client *signaling.Client, sessionID,
fserrors.ErrRelayCapHit, uxlog.HumanBytes(int64(status.LimitBytes)))
}
return fserrors.ErrRelayCapHit
case relay.ReasonBudgetHit:
return fserrors.ErrRelayBudgetExhausted
case relay.ReasonIdle:
return fserrors.ErrRelayIdleTimeout
}
Expand Down
6 changes: 6 additions & 0 deletions cmd/fsend/internet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ func TestClassifyRelayDrop_MapsReasons(t *testing.T) {
runErr: errors.New("idle timeout"),
wantErr: fserrors.ErrRelayIdleTimeout,
},
{
name: "daily_budget_is_bare_sentinel",
body: `{"state":"evicted","reason":"daily_budget"}`,
runErr: errors.New("connection reset"),
wantErr: fserrors.ErrRelayBudgetExhausted,
},
{
name: "active_keeps_run_err",
body: `{"state":"active"}`,
Expand Down
12 changes: 12 additions & 0 deletions cmd/fsend/sendpair.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,15 @@ func pairOverInternet(ctx context.Context, f *flags, code string, cfg *config.Co
// Establish the underlying data path: ICE-direct first, relay fallback.
pc, pathInfo, err := establishInternetDataPath(ctx, f, client, created, waitResp)
if err != nil {
// A spent daily budget is terminal and self-explanatory. Surface it
// as-is (not the generic errPairedGone), and leave the session for the
// receiver to reach the relay and learn the same reason — it's the
// budget, not the pairing, that failed. The session (and its per-IP
// count) is reclaimed at PairedTTL rather than now; fine since the
// budget is already the degraded state and the message says to wait.
if errors.Is(err, fserrors.ErrRelayBudgetExhausted) {
return nil, err
}
deleteSession()
if ctx.Err() == nil {
err = fmt.Errorf("%w: %v", errPairedGone, err)
Expand Down Expand Up @@ -357,6 +366,9 @@ func allocAndDialRelay(ctx context.Context, client *signaling.Client, sessionID,
if err != nil {
return nil, connpath.Info{}, fmt.Errorf("%w: %v", fserrors.ErrConnectFailed, err)
}
if alloc.BudgetExhausted {
return nil, connpath.Info{}, fserrors.ErrRelayBudgetExhausted
}
if alloc.ForwardingDisabled {
return nil, connpath.Info{}, fmt.Errorf("%w: this server has relay forwarding disabled", fserrors.ErrConnectFailed)
}
Expand Down
45 changes: 31 additions & 14 deletions cmd/fsend/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ func runServer() error {
defer func() { _ = udpListener.Close() }()
relaySrv := relay.NewServer(udpListener, relay.ServerConfig{
MaxBytesPerSession: cfg.maxBytesPerSession,
MaxBytesPerDay: cfg.maxBytesPerDay,
Logger: logger,
DisableForwarding: !cfg.enableRelay,
})
Expand Down Expand Up @@ -202,22 +203,24 @@ func drainRelay(ctx context.Context, r *relay.Server, logger *slog.Logger) {
// loader and the startup summary so the two can't drift on a rename or a
// changed default.
const (
envPairingAddr = "FSEND_PAIRING_ADDR"
envServerAddr = "FSEND_SERVER_ADDR"
envRelayAddr = "FSEND_RELAY_ADDR"
envMaxSessionsPerIP = "FSEND_SERVER_MAX_SESSIONS_PER_IP"
envMaxNewSessionsPerMin = "FSEND_SERVER_MAX_SESSIONS_PER_IP_PER_MIN"
envMaxNewSessionsPerMin = "FSEND_SERVER_MAX_SESSIONS_PER_IP_PER_MINUTE"
envMaxBytesPerSession = "FSEND_RELAY_MAX_BYTES_PER_SESSION"
envMaxBytesPerDay = "FSEND_RELAY_MAX_BYTES_PER_DAY"
envRelayEnabled = "FSEND_RELAY_ENABLED"
envLogLevel = "FSEND_LOG_LEVEL"

defaultPairingAddr = ":8080"
defaultRelayAddr = ":443"
// The three caps default to 0 = unlimited: an unconfigured server
// imposes no per-IP session limits and no relay byte cap. Operators
// opt into protection by setting a positive value.
defaultServerAddr = ":8080"
defaultRelayAddr = ":443"
// The caps default to 0 = unlimited: an unconfigured server imposes no
// per-IP session limits and no relay byte caps. Operators opt into
// protection by setting a positive value.
defaultMaxSessionsPerIP = 0
defaultMaxNewSessionsPerMin = 0
defaultMaxBytesPerSession = 0
defaultMaxBytesPerDay = 0
defaultRelayEnabled = true
)

Expand All @@ -229,13 +232,14 @@ type serverRuntimeConfig struct {
maxSessionsPerIP int
maxNewSessionsPerMin int
maxBytesPerSession uint64
maxBytesPerDay uint64
serverPassword string
enableRelay bool
}

func loadServerConfig() (serverRuntimeConfig, error) {
cfg := serverRuntimeConfig{
httpAddr: envOr(envPairingAddr, defaultPairingAddr),
httpAddr: envOr(envServerAddr, defaultServerAddr),
udpAddr: envOr(envRelayAddr, defaultRelayAddr),
serverPassword: os.Getenv("FSEND_SERVER_PASSWORD"),
}
Expand All @@ -249,6 +253,9 @@ func loadServerConfig() (serverRuntimeConfig, error) {
if cfg.maxBytesPerSession, err = envBytes(envMaxBytesPerSession, defaultMaxBytesPerSession); err != nil {
return cfg, err
}
if cfg.maxBytesPerDay, err = envBytes(envMaxBytesPerDay, defaultMaxBytesPerDay); err != nil {
return cfg, err
}
if cfg.enableRelay, err = envBool(envRelayEnabled, defaultRelayEnabled); err != nil {
return cfg, err
}
Expand Down Expand Up @@ -281,16 +288,20 @@ func formatServerConfig(cfg serverRuntimeConfig) string {
password = "(set)"
}
settings := []setting{
// Grouped: server-wide (log, password), then the pairing control
// plane (its listener + session limits), then the relay data plane
// (its listener + byte limits). Matches the docs table order.
{envLogLevel, logLevelName(cfg.logLevel), cfg.logLevel != slog.LevelInfo},
// Env-var name inlined (not a shared const) so gosec G101 doesn't
// mistake a password-named identifier for a hardcoded credential.
{"FSEND_SERVER_PASSWORD", password, cfg.serverPassword != ""},
{envServerAddr, cfg.httpAddr, cfg.httpAddr != defaultServerAddr},
{envMaxSessionsPerIP, capCount(cfg.maxSessionsPerIP), cfg.maxSessionsPerIP != defaultMaxSessionsPerIP},
{envMaxNewSessionsPerMin, capCount(cfg.maxNewSessionsPerMin), cfg.maxNewSessionsPerMin != defaultMaxNewSessionsPerMin},
{envPairingAddr, cfg.httpAddr, cfg.httpAddr != defaultPairingAddr},
{envRelayEnabled, strconv.FormatBool(cfg.enableRelay), !cfg.enableRelay},
{envRelayAddr, cfg.udpAddr, cfg.udpAddr != defaultRelayAddr},
{envMaxBytesPerSession, capBytes(cfg.maxBytesPerSession), cfg.maxBytesPerSession != defaultMaxBytesPerSession},
{envMaxBytesPerDay, capBytes(cfg.maxBytesPerDay), cfg.maxBytesPerDay != defaultMaxBytesPerDay},
}

width, modified := 0, 0
Expand Down Expand Up @@ -439,7 +450,7 @@ func envBytes(name string, def uint64) (uint64, error) {
// HTTP address. Exits 0 on healthy, 1 on anything else. Designed for
// Docker HEALTHCHECK.
func healthCheck() error {
addr := envOr(envPairingAddr, defaultPairingAddr)
addr := envOr(envServerAddr, defaultServerAddr)
if strings.HasPrefix(addr, ":") {
addr = "127.0.0.1" + addr
}
Expand Down Expand Up @@ -482,18 +493,18 @@ CONFIGURATION (environment variables — all optional)
endpoints except /v1/health require the
X-Fsend-Auth header. Connect with
fsend --connect <host:port>,<password>.

Pairing (TCP signaling/control plane):
FSEND_SERVER_ADDR Default :8080 (TCP).
FSEND_SERVER_MAX_SESSIONS_PER_IP How many sessions one IP may have
alive at once (concurrency cap; gates
relay access too). Default 0 =
unlimited; set a positive value to cap.
FSEND_SERVER_MAX_SESSIONS_PER_IP_PER_MIN
FSEND_SERVER_MAX_SESSIONS_PER_IP_PER_MINUTE
How many new sessions one IP may create
per minute (rate cap). Default 0 =
unlimited; set a positive value to cap.

Pairing (TCP signaling/control plane):
FSEND_PAIRING_ADDR Default :8080 (TCP).

Relay (UDP data plane — also answers STUN):
FSEND_RELAY_ENABLED Default true. false = pairing + STUN
only: peers still hole-punch, but the
Expand All @@ -505,6 +516,12 @@ CONFIGURATION (environment variables — all optional)
compression. Accepts B, KB, MB, GB,
TB, or a plain byte count. Default 0 =
unlimited; set a value to cap.
FSEND_RELAY_MAX_BYTES_PER_DAY Server-wide outbound bytes forwarded
per UTC day — the egress/cost ceiling.
Each byte counts once (a 1 MB relayed
file ≈ 1 MB). Same units. Default 0 =
unlimited; once hit, the relay stops
forwarding until 00:00 UTC.

LEARN MORE
https://github.com/polius/fsend
Expand Down
2 changes: 1 addition & 1 deletion cmd/fsend/server_lifecycle_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func TestServerLifecycle(t *testing.T) {
udpPort := srvFreePortUDP(t)
httpAddr := fmt.Sprintf("127.0.0.1:%d", httpPort)
udpAddr := fmt.Sprintf("127.0.0.1:%d", udpPort)
t.Setenv("FSEND_PAIRING_ADDR", httpAddr)
t.Setenv("FSEND_SERVER_ADDR", httpAddr)
t.Setenv("FSEND_RELAY_ADDR", udpAddr)
t.Setenv("FSEND_LOG_LEVEL", "error")

Expand Down
54 changes: 31 additions & 23 deletions cmd/fsend/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,13 @@ func TestServerEnvBytes(t *testing.T) {
func clearServerEnv(t *testing.T) {
t.Helper()
for _, k := range []string{
"FSEND_PAIRING_ADDR",
"FSEND_SERVER_ADDR",
"FSEND_RELAY_ADDR",
"FSEND_LOG_LEVEL",
"FSEND_SERVER_MAX_SESSIONS_PER_IP",
"FSEND_SERVER_MAX_SESSIONS_PER_IP_PER_MIN",
"FSEND_SERVER_MAX_SESSIONS_PER_IP_PER_MINUTE",
"FSEND_RELAY_MAX_BYTES_PER_SESSION",
"FSEND_RELAY_MAX_BYTES_PER_DAY",
} {
os.Unsetenv(k)
}
Expand Down Expand Up @@ -181,18 +182,22 @@ func TestServerLoadConfigDefaults(t *testing.T) {
if cfg.maxBytesPerSession != 0 {
t.Errorf("maxBytesPerSession: got %d, want 0 (unlimited)", cfg.maxBytesPerSession)
}
if cfg.maxBytesPerDay != 0 {
t.Errorf("maxBytesPerDay: got %d, want 0 (unlimited)", cfg.maxBytesPerDay)
}
if cfg.logLevel != slog.LevelInfo {
t.Errorf("logLevel: got %v, want info", cfg.logLevel)
}
}

func TestServerLoadConfigOverrides(t *testing.T) {
clearServerEnv(t)
t.Setenv("FSEND_PAIRING_ADDR", ":19999")
t.Setenv("FSEND_SERVER_ADDR", ":19999")
t.Setenv("FSEND_RELAY_ADDR", ":29999")
t.Setenv("FSEND_SERVER_MAX_SESSIONS_PER_IP", "12")
t.Setenv("FSEND_SERVER_MAX_SESSIONS_PER_IP_PER_MIN", "120")
t.Setenv("FSEND_RELAY_MAX_BYTES_PER_SESSION", "250MB")
t.Setenv("FSEND_SERVER_MAX_SESSIONS_PER_IP_PER_MINUTE", "120")
t.Setenv("FSEND_RELAY_MAX_BYTES_PER_SESSION", "2MB")
t.Setenv("FSEND_RELAY_MAX_BYTES_PER_DAY", "10MB")
cfg, err := loadServerConfig()
if err != nil {
t.Fatalf("loadServerConfig: %v", err)
Expand All @@ -209,17 +214,20 @@ func TestServerLoadConfigOverrides(t *testing.T) {
if cfg.maxNewSessionsPerMin != 120 {
t.Errorf("maxNewSessionsPerMin: got %d", cfg.maxNewSessionsPerMin)
}
if cfg.maxBytesPerSession != 250*srvMB {
if cfg.maxBytesPerSession != 2*srvMB {
t.Errorf("maxBytesPerSession: got %d", cfg.maxBytesPerSession)
}
if cfg.maxBytesPerDay != 10*srvMB {
t.Errorf("maxBytesPerDay: got %d", cfg.maxBytesPerDay)
}
}

func TestServerLoadConfigRejectsBadValues(t *testing.T) {
// A typo'd limit must stop the server, not silently run the default.
cases := map[string]string{
"FSEND_RELAY_MAX_BYTES_PER_SESSION": "1GiB",
"FSEND_SERVER_MAX_SESSIONS_PER_IP": "many",
"FSEND_SERVER_MAX_SESSIONS_PER_IP_PER_MIN": "-1",
"FSEND_RELAY_MAX_BYTES_PER_SESSION": "1GiB",
"FSEND_SERVER_MAX_SESSIONS_PER_IP": "many",
"FSEND_SERVER_MAX_SESSIONS_PER_IP_PER_MINUTE": "-1",
}
for k, v := range cases {
t.Run(k+"="+v, func(t *testing.T) {
Expand Down Expand Up @@ -262,7 +270,7 @@ func TestServerLoadConfigLogLevels(t *testing.T) {

func defaultServerConfig() serverRuntimeConfig {
return serverRuntimeConfig{
httpAddr: defaultPairingAddr,
httpAddr: defaultServerAddr,
udpAddr: defaultRelayAddr,
logLevel: slog.LevelInfo,
maxSessionsPerIP: defaultMaxSessionsPerIP,
Expand All @@ -277,15 +285,15 @@ func TestFormatServerConfig_AllDefaults(t *testing.T) {
if strings.Contains(out, "* FSEND") {
t.Errorf("all-default config must mark nothing customized:\n%s", out)
}
if !strings.Contains(out, "0 of 8 customized") {
t.Errorf("want '0 of 8 customized':\n%s", out)
if !strings.Contains(out, "0 of 9 customized") {
t.Errorf("want '0 of 9 customized':\n%s", out)
}
if !strings.Contains(out, "(not set)") {
t.Errorf("password must read '(not set)' when empty:\n%s", out)
}
// The three caps default to 0 and must render as "0 (unlimited)".
if n := strings.Count(out, "0 (unlimited)"); n != 3 {
t.Errorf("want 3 '0 (unlimited)' lines (the default caps), got %d:\n%s", n, out)
// The four caps default to 0 and must render as "0 (unlimited)".
if n := strings.Count(out, "0 (unlimited)"); n != 4 {
t.Errorf("want 4 '0 (unlimited)' lines (the default caps), got %d:\n%s", n, out)
}
}

Expand All @@ -311,32 +319,32 @@ func TestFormatServerConfig_Overrides(t *testing.T) {
if !strings.Contains(out, "0 (unlimited)") {
t.Errorf("default caps must still render as '0 (unlimited)':\n%s", out)
}
if !strings.Contains(out, "3 of 8 customized") {
t.Errorf("want '3 of 8 customized':\n%s", out)
if !strings.Contains(out, "3 of 9 customized") {
t.Errorf("want '3 of 9 customized':\n%s", out)
}
for _, name := range []string{"FSEND_SERVER_PASSWORD", envMaxSessionsPerIP, envRelayEnabled} {
if !strings.Contains(out, "* "+name) {
t.Errorf("expected '* %s' marker line:\n%s", name, out)
}
}
// An unchanged setting must stay unmarked.
if strings.Contains(out, "* "+envPairingAddr) {
t.Errorf("unchanged %s must not be marked:\n%s", envPairingAddr, out)
if strings.Contains(out, "* "+envServerAddr) {
t.Errorf("unchanged %s must not be marked:\n%s", envServerAddr, out)
}
}

func TestServerHealthCheckUnreachable(t *testing.T) {
// Bind+close to take a known-free port, then probe it (nothing listens).
port := srvFreePortTCP(t)
t.Setenv("FSEND_PAIRING_ADDR", fmt.Sprintf("127.0.0.1:%d", port))
t.Setenv("FSEND_SERVER_ADDR", fmt.Sprintf("127.0.0.1:%d", port))
if err := healthCheck(); err == nil {
t.Fatal("expected error against closed port")
}
}

func TestServerHealthCheckBadStatus(t *testing.T) {
addr := startStubHealth(t, http.StatusInternalServerError, nil)
t.Setenv("FSEND_PAIRING_ADDR", addr)
t.Setenv("FSEND_SERVER_ADDR", addr)
err := healthCheck()
if err == nil {
t.Fatal("expected error for 500 response")
Expand All @@ -345,15 +353,15 @@ func TestServerHealthCheckBadStatus(t *testing.T) {

func TestServerHealthCheckOK(t *testing.T) {
addr := startStubHealth(t, http.StatusOK, []byte(`{"ok":true}`))
t.Setenv("FSEND_PAIRING_ADDR", addr)
t.Setenv("FSEND_SERVER_ADDR", addr)
if err := healthCheck(); err != nil {
t.Errorf("healthCheck: %v", err)
}
}

// startStubHealth boots a minimal HTTP server that answers /v1/health with
// the given status + body, and shuts it down when the test ends. Returns
// the host:port the test should set as FSEND_PAIRING_ADDR.
// the host:port the test should set as FSEND_SERVER_ADDR.
func startStubHealth(t *testing.T, status int, body []byte) string {
t.Helper()
port := srvFreePortTCP(t)
Expand Down
6 changes: 3 additions & 3 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ For internet transfers (one peer behind NAT), the client needs a pairing
server. To exercise the full client↔server↔client path:

```sh
FSEND_PAIRING_ADDR=":18080" \
FSEND_SERVER_ADDR=":18080" \
FSEND_RELAY_ADDR=":18443" \
FSEND_LOG_LEVEL=debug \
/tmp/fsend server
Expand All @@ -93,15 +93,15 @@ Reset to the public default:
Health check (the env var tells the probe where to look):

```sh
FSEND_PAIRING_ADDR=":18080" /tmp/fsend server --health-check
FSEND_SERVER_ADDR=":18080" /tmp/fsend server --health-check
```

### Common server env vars

| Var | Default | Notes |
|---|---|---|
| `FSEND_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
| `FSEND_PAIRING_ADDR` | `:8080` | Signaling listener (TCP) |
| `FSEND_SERVER_ADDR` | `:8080` | Signaling listener (TCP) |
| `FSEND_RELAY_ADDR` | `:443` | Relay listener (UDP); also the STUN endpoint |
| `FSEND_RELAY_MAX_BYTES_PER_SESSION` | `1GB` | Per-session relay cap (wire bytes, post-compression). |

Expand Down
Loading
Loading