Files
silo-server/internal/clientip/config.go
5f59f8e952 feat(clientip): expose trusted proxy CIDRs in the Admin UI and via SILO_TRUSTED_PROXIES (#310)
* feat(clientip): expose trusted proxy CIDRs in the admin UI and via env var

Trusted reverse-proxy CIDRs (clientip.trusted_proxies) previously required
hand-editing server_settings via SQL and a restart. Now:

- Admin UI: a Network > Trusted Proxies field on the General settings page,
  with server-side CIDR validation and normalization on save.
- Env var: SILO_TRUSTED_PROXIES is validated at startup and persisted to
  server_settings (re-applied on every boot while set), so Docker operators
  never touch the database and the UI shows the effective value.
- Hot reload: the setting now rides the nodeconfig watcher snapshot, so
  changes apply without restart on Redis-less deployments too (previously
  reload only worked via the Redis event bus, and only when rate limiting
  was enabled).

Closes #300

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(clientip): keep key-scoped event-bus reload alongside the config watcher

A malformed unrelated setting fails the whole-config watcher reload; the
direct subscription re-reads only clientip.trusted_proxies so the trust
boundary still updates on Redis-backed multi-instance deployments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(clientip): key-scoped same-process reload in OnServerSettingUpdated

Covers the Redis-less path: an unrelated malformed setting that fails the
whole-config watcher reload can no longer leave stale trusted-proxy CIDRs
after a successful admin save.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(clientip): reload with a fresh context in OnServerSettingUpdated

The setting is already persisted when the hook runs; a canceled admin
request must not skip the trust-boundary reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(web): wrap long trusted-proxies hint to the 100-char width

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): add guidance tip for trusted proxy ranges

Explains that the setting replaces the private-network defaults, the
recommended /32 pattern, CDN multi-range caveats (Cloudflare), and why
0.0.0.0/0 is unsafe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:44:36 -04:00

106 lines
3.2 KiB
Go

package clientip
import (
"context"
"fmt"
"net"
"os"
"strings"
)
// SettingsStore is satisfied by *catalog.ServerSettingsRepo.
type SettingsStore interface {
Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key, value string) error
GetAll(ctx context.Context) (map[string]string, error)
}
const (
// SettingTrustedProxies is the server_settings key holding the
// comma-separated CIDR list of trusted reverse proxies.
SettingTrustedProxies = "clientip.trusted_proxies"
// EnvTrustedProxies overrides SettingTrustedProxies at startup when set.
EnvTrustedProxies = "SILO_TRUSTED_PROXIES"
// DefaultTrustedProxies: RFC 1918 private ranges + loopback.
DefaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,::1/128"
)
// SeedDefaults ensures the trusted proxy setting exists. When the
// SILO_TRUSTED_PROXIES environment variable is set it wins: the value is
// validated and persisted so the admin UI shows the effective config (and is
// re-applied on every startup while the variable remains set). Otherwise the
// default list is written only if the setting is unset.
func SeedDefaults(ctx context.Context, store SettingsStore) error {
if env := strings.TrimSpace(os.Getenv(EnvTrustedProxies)); env != "" {
normalized, err := NormalizeCIDRList(env)
if err != nil {
return fmt.Errorf("invalid %s: %w", EnvTrustedProxies, err)
}
existing, err := store.Get(ctx, SettingTrustedProxies)
if err != nil {
return fmt.Errorf("seed clientip defaults: %w", err)
}
if existing == normalized {
return nil
}
return store.Set(ctx, SettingTrustedProxies, normalized)
}
existing, err := store.Get(ctx, SettingTrustedProxies)
if err != nil {
return fmt.Errorf("seed clientip defaults: %w", err)
}
if existing != "" {
return nil
}
return store.Set(ctx, SettingTrustedProxies, DefaultTrustedProxies)
}
// LoadTrustedCIDRs reads the trusted proxy CIDRs from settings.
func LoadTrustedCIDRs(ctx context.Context, store SettingsStore) ([]*net.IPNet, error) {
raw, err := store.Get(ctx, SettingTrustedProxies)
if err != nil {
return nil, fmt.Errorf("load trusted proxies: %w", err)
}
if raw == "" {
raw = DefaultTrustedProxies
}
return ParseCIDRs(raw)
}
// NormalizeCIDRList validates a comma-separated CIDR list and returns it in
// canonical form (trimmed entries joined by ", "). An empty list is valid and
// means "use the built-in defaults".
func NormalizeCIDRList(raw string) (string, error) {
parts := strings.Split(raw, ",")
var out []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, _, err := net.ParseCIDR(p); err != nil {
return "", fmt.Errorf("invalid CIDR %q (expected e.g. 203.0.113.7/32)", p)
}
out = append(out, p)
}
return strings.Join(out, ", "), nil
}
// ParseCIDRs parses a comma-separated list of CIDR strings.
func ParseCIDRs(raw string) ([]*net.IPNet, error) {
parts := strings.Split(raw, ",")
var cidrs []*net.IPNet
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
_, cidr, err := net.ParseCIDR(p)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q: %w", p, err)
}
cidrs = append(cidrs, cidr)
}
return cidrs, nil
}