* 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>
120 lines
3.0 KiB
Go
120 lines
3.0 KiB
Go
package clientip
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
)
|
|
|
|
type fakeStore struct {
|
|
values map[string]string
|
|
sets int
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{values: map[string]string{}}
|
|
}
|
|
|
|
func (s *fakeStore) Get(_ context.Context, key string) (string, error) {
|
|
return s.values[key], nil
|
|
}
|
|
|
|
func (s *fakeStore) Set(_ context.Context, key, value string) error {
|
|
s.values[key] = value
|
|
s.sets++
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeStore) GetAll(_ context.Context) (map[string]string, error) {
|
|
return s.values, nil
|
|
}
|
|
|
|
func TestSeedDefaultsWritesDefaultsWhenUnset(t *testing.T) {
|
|
t.Setenv(EnvTrustedProxies, "")
|
|
store := newFakeStore()
|
|
if err := SeedDefaults(context.Background(), store); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := store.values[SettingTrustedProxies]; got != DefaultTrustedProxies {
|
|
t.Fatalf("got %q, want defaults", got)
|
|
}
|
|
}
|
|
|
|
func TestSeedDefaultsKeepsExistingValue(t *testing.T) {
|
|
t.Setenv(EnvTrustedProxies, "")
|
|
store := newFakeStore()
|
|
store.values[SettingTrustedProxies] = "203.0.113.0/24"
|
|
if err := SeedDefaults(context.Background(), store); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if store.sets != 0 {
|
|
t.Fatalf("expected no writes, got %d", store.sets)
|
|
}
|
|
}
|
|
|
|
func TestSeedDefaultsEnvOverridesExistingValue(t *testing.T) {
|
|
t.Setenv(EnvTrustedProxies, " 10.0.0.0/8 ,203.0.113.7/32 ")
|
|
store := newFakeStore()
|
|
store.values[SettingTrustedProxies] = "192.168.0.0/16"
|
|
if err := SeedDefaults(context.Background(), store); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := "10.0.0.0/8, 203.0.113.7/32"
|
|
if got := store.values[SettingTrustedProxies]; got != want {
|
|
t.Fatalf("got %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSeedDefaultsEnvIdempotent(t *testing.T) {
|
|
t.Setenv(EnvTrustedProxies, "203.0.113.7/32")
|
|
store := newFakeStore()
|
|
store.values[SettingTrustedProxies] = "203.0.113.7/32"
|
|
if err := SeedDefaults(context.Background(), store); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if store.sets != 0 {
|
|
t.Fatalf("expected no writes for unchanged env value, got %d", store.sets)
|
|
}
|
|
}
|
|
|
|
func TestSeedDefaultsEnvInvalid(t *testing.T) {
|
|
t.Setenv(EnvTrustedProxies, "not-a-cidr")
|
|
store := newFakeStore()
|
|
if err := SeedDefaults(context.Background(), store); err == nil {
|
|
t.Fatal("expected error for invalid env CIDR list")
|
|
}
|
|
if store.sets != 0 {
|
|
t.Fatalf("expected no writes on invalid env, got %d", store.sets)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeCIDRList(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{"", "", false},
|
|
{" , ,", "", false},
|
|
{"10.0.0.0/8", "10.0.0.0/8", false},
|
|
{" 10.0.0.0/8, ::1/128 ", "10.0.0.0/8, ::1/128", false},
|
|
{"10.0.0.1", "", true}, // bare IP, no prefix
|
|
{"garbage/33", "", true}, // invalid prefix
|
|
}
|
|
for _, tc := range cases {
|
|
got, err := NormalizeCIDRList(tc.in)
|
|
if tc.wantErr {
|
|
if err == nil {
|
|
t.Errorf("NormalizeCIDRList(%q): expected error", tc.in)
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
t.Errorf("NormalizeCIDRList(%q): %v", tc.in, err)
|
|
continue
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("NormalizeCIDRList(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|