From 8ab3a39b38a50061ae782fd839950817f7fd5a00 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Wed, 20 May 2026 19:40:06 -0600 Subject: [PATCH 1/4] feat(config): add applyDefaultHost / walkAddresses / ParseHostFlag helpers Prepare for binding listeners to localhost by default. The helpers added here are the building blocks; the parser, CLI flag, and embedded default config are wired up in follow-up commits. - applyDefaultHost rewrites bare ":PORT" / "PORT" addrs by prepending a default host; explicit hosts are preserved so operator overrides win. - walkAddresses applies the helper across services / metrics / profiling. - ParseHostFlag validates the --host flag, accepting IP literals only. Tests cover IPv4, IPv6, unix sockets, empty input, and bracket handling. --- internal/gostx/config/parsing/parser/host.go | 90 +++++++++++++++ .../gostx/config/parsing/parser/host_test.go | 109 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 internal/gostx/config/parsing/parser/host.go create mode 100644 internal/gostx/config/parsing/parser/host_test.go diff --git a/internal/gostx/config/parsing/parser/host.go b/internal/gostx/config/parsing/parser/host.go new file mode 100644 index 00000000..21c7aac9 --- /dev/null +++ b/internal/gostx/config/parsing/parser/host.go @@ -0,0 +1,90 @@ +package parser + +import ( + "fmt" + "net" + "strconv" + "strings" + + "github.com/greyhavenhq/greyproxy/internal/gostx/config" +) + +// DefaultHost is the address greyproxy binds to when no host is specified +// in the config or on the command line. Loopback is the safe default — see +// SECURITY.md for the rationale. +const DefaultHost = "127.0.0.1" + +// ParseHostFlag validates the value of the --host CLI flag and the top-level +// `host:` YAML field. It accepts IP literals only (IPv4 or IPv6); hostnames +// are rejected so that operators don't have to wonder which resolved address +// the listener picked. +func ParseHostFlag(s string) (string, error) { + if s == "" { + return "", nil + } + if net.ParseIP(s) == nil { + return "", fmt.Errorf("host requires a literal IP address, got %q", s) + } + return s, nil +} + +// applyDefaultHost prepends host to addr when addr is a bare port form like +// ":43080" or "43080". Addresses that already carry a host part (explicit +// 0.0.0.0, 127.0.0.1, a LAN IP, [::]/[::1], etc.) are returned unchanged so +// operator overrides win. Non-TCP URI forms ("unix://...") and the empty +// string are also returned unchanged. +func applyDefaultHost(addr, host string) string { + if addr == "" { + return "" + } + if strings.Contains(addr, "://") { + return addr + } + h, port, err := net.SplitHostPort(addr) + if err != nil { + if _, perr := strconv.Atoi(addr); perr == nil { + return net.JoinHostPort(host, addr) + } + return addr + } + if h != "" { + return addr + } + return net.JoinHostPort(host, port) +} + +// walkAddresses applies applyDefaultHost to every listener address in cfg: +// each service, the metrics endpoint, and the pprof endpoint. The greyproxy +// dashboard address lives under the `greyproxy:` viper subtree and is +// normalized separately in cmd/greyproxy/program.go. +func walkAddresses(cfg *config.Config, host string) { + if cfg == nil { + return + } + for _, svc := range cfg.Services { + if svc == nil { + continue + } + svc.Addr = applyDefaultHost(svc.Addr, host) + } + if cfg.Metrics != nil { + cfg.Metrics.Addr = applyDefaultHost(cfg.Metrics.Addr, host) + } + if cfg.Profiling != nil { + cfg.Profiling.Addr = applyDefaultHost(cfg.Profiling.Addr, host) + } +} + +// IsUnspecifiedBind returns true when addr binds to all interfaces +// (0.0.0.0 / ::), so callers can warn the operator at startup. +func IsUnspecifiedBind(addr string) bool { + if addr == "" { + return false + } + h, _, err := net.SplitHostPort(addr) + if err != nil { + return false + } + ip := net.ParseIP(h) + return ip != nil && ip.IsUnspecified() +} diff --git a/internal/gostx/config/parsing/parser/host_test.go b/internal/gostx/config/parsing/parser/host_test.go new file mode 100644 index 00000000..35fbc747 --- /dev/null +++ b/internal/gostx/config/parsing/parser/host_test.go @@ -0,0 +1,109 @@ +package parser + +import ( + "testing" + + "github.com/greyhavenhq/greyproxy/internal/gostx/config" +) + +func TestApplyDefaultHost(t *testing.T) { + tests := []struct { + name string + addr string + host string + want string + }{ + {"bare colon port", ":43080", "127.0.0.1", "127.0.0.1:43080"}, + {"bare numeric", "43080", "127.0.0.1", "127.0.0.1:43080"}, + {"explicit ipv4 unspecified", "0.0.0.0:43080", "127.0.0.1", "0.0.0.0:43080"}, + {"explicit ipv4 loopback", "127.0.0.1:43080", "127.0.0.1", "127.0.0.1:43080"}, + {"explicit ipv4 lan", "192.168.1.5:43080", "127.0.0.1", "192.168.1.5:43080"}, + {"explicit ipv6 unspecified", "[::]:43080", "127.0.0.1", "[::]:43080"}, + {"explicit ipv6 loopback", "[::1]:43080", "127.0.0.1", "[::1]:43080"}, + {"unix socket left alone", "unix:///tmp/foo.sock", "127.0.0.1", "unix:///tmp/foo.sock"}, + {"empty left alone", "", "127.0.0.1", ""}, + {"host overrides to unspecified", ":43080", "0.0.0.0", "0.0.0.0:43080"}, + {"ipv6 host with brackets", ":43080", "::1", "[::1]:43080"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := applyDefaultHost(tc.addr, tc.host) + if got != tc.want { + t.Errorf("applyDefaultHost(%q, %q) = %q, want %q", tc.addr, tc.host, got, tc.want) + } + }) + } +} + +func TestWalkAddresses(t *testing.T) { + cfg := &config.Config{ + Services: []*config.ServiceConfig{ + {Name: "bare", Addr: ":43051"}, + {Name: "explicit", Addr: "0.0.0.0:43052"}, + {Name: "lan", Addr: "192.168.1.5:43053"}, + }, + Metrics: &config.MetricsConfig{Addr: ":9100"}, + Profiling: &config.ProfilingConfig{Addr: ":6060"}, + } + walkAddresses(cfg, "127.0.0.1") + + if cfg.Services[0].Addr != "127.0.0.1:43051" { + t.Errorf("bare service: got %q", cfg.Services[0].Addr) + } + if cfg.Services[1].Addr != "0.0.0.0:43052" { + t.Errorf("explicit service rewritten: got %q", cfg.Services[1].Addr) + } + if cfg.Services[2].Addr != "192.168.1.5:43053" { + t.Errorf("lan service rewritten: got %q", cfg.Services[2].Addr) + } + if cfg.Metrics.Addr != "127.0.0.1:9100" { + t.Errorf("metrics: got %q", cfg.Metrics.Addr) + } + if cfg.Profiling.Addr != "127.0.0.1:6060" { + t.Errorf("profiling: got %q", cfg.Profiling.Addr) + } +} + +func TestWalkAddressesNilSections(t *testing.T) { + cfg := &config.Config{} + walkAddresses(cfg, "127.0.0.1") + if cfg.Metrics != nil || cfg.Profiling != nil { + t.Errorf("walkAddresses should not create empty Metrics/Profiling sections") + } +} + +func TestParseHostFlag(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {"empty allowed", "", "", false}, + {"ipv4 loopback", "127.0.0.1", "127.0.0.1", false}, + {"ipv4 unspecified", "0.0.0.0", "0.0.0.0", false}, + {"ipv4 lan", "192.168.1.10", "192.168.1.10", false}, + {"ipv6 loopback", "::1", "::1", false}, + {"ipv6 unspecified", "::", "::", false}, + {"hostname rejected", "my-laptop.local", "", true}, + {"garbage rejected", "not-an-ip", "", true}, + {"host:port rejected", "127.0.0.1:43080", "", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseHostFlag(tc.input) + if tc.wantErr { + if err == nil { + t.Errorf("ParseHostFlag(%q): expected error, got nil", tc.input) + } + return + } + if err != nil { + t.Errorf("ParseHostFlag(%q): unexpected error %v", tc.input, err) + } + if got != tc.want { + t.Errorf("ParseHostFlag(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} From 22a307c7986e7d5d2af2caebff44762c294c8522 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Wed, 20 May 2026 19:47:07 -0600 Subject: [PATCH 2/4] feat(parser): wire --host flag and top-level host: into the parser Plumbs the resolved bind host (CLI flag > YAML host: > 127.0.0.1) through parser.Parse so service / metrics / profiling addresses written as a bare port get rewritten before listeners are constructed. Explicit hosts are left alone so operator overrides keep winning. The expanded parser_test.go runs a full YAML roundtrip and confirms net.Listen with the normalized address actually produces a loopback (or unspecified, with --host 0.0.0.0) socket on the test platform. --- internal/gostx/config/config.go | 5 + internal/gostx/config/parsing/parser/host.go | 12 +- .../gostx/config/parsing/parser/host_test.go | 124 +++++++++++++++++- .../gostx/config/parsing/parser/parser.go | 24 ++++ 4 files changed, 157 insertions(+), 8 deletions(-) diff --git a/internal/gostx/config/config.go b/internal/gostx/config/config.go index 56a7da26..16beccd6 100644 --- a/internal/gostx/config/config.go +++ b/internal/gostx/config/config.go @@ -444,6 +444,11 @@ type NodeConfig struct { } type Config struct { + // Host is the default bind interface for any listener whose addr is a + // bare port (":43080" / "43080"). Defaults to 127.0.0.1; overridden by + // the --host CLI flag. Explicit hosts in service / metrics / profiling + // addrs are left alone. + Host string `yaml:"host,omitempty" json:"host,omitempty"` Services []*ServiceConfig `json:"services"` Authers []*AutherConfig `yaml:",omitempty" json:"authers,omitempty"` Admissions []*AdmissionConfig `yaml:",omitempty" json:"admissions,omitempty"` diff --git a/internal/gostx/config/parsing/parser/host.go b/internal/gostx/config/parsing/parser/host.go index 21c7aac9..7385a37b 100644 --- a/internal/gostx/config/parsing/parser/host.go +++ b/internal/gostx/config/parsing/parser/host.go @@ -28,12 +28,12 @@ func ParseHostFlag(s string) (string, error) { return s, nil } -// applyDefaultHost prepends host to addr when addr is a bare port form like +// ApplyDefaultHost prepends host to addr when addr is a bare port form like // ":43080" or "43080". Addresses that already carry a host part (explicit // 0.0.0.0, 127.0.0.1, a LAN IP, [::]/[::1], etc.) are returned unchanged so // operator overrides win. Non-TCP URI forms ("unix://...") and the empty // string are also returned unchanged. -func applyDefaultHost(addr, host string) string { +func ApplyDefaultHost(addr, host string) string { if addr == "" { return "" } @@ -53,7 +53,7 @@ func applyDefaultHost(addr, host string) string { return net.JoinHostPort(host, port) } -// walkAddresses applies applyDefaultHost to every listener address in cfg: +// walkAddresses applies ApplyDefaultHost to every listener address in cfg: // each service, the metrics endpoint, and the pprof endpoint. The greyproxy // dashboard address lives under the `greyproxy:` viper subtree and is // normalized separately in cmd/greyproxy/program.go. @@ -65,13 +65,13 @@ func walkAddresses(cfg *config.Config, host string) { if svc == nil { continue } - svc.Addr = applyDefaultHost(svc.Addr, host) + svc.Addr = ApplyDefaultHost(svc.Addr, host) } if cfg.Metrics != nil { - cfg.Metrics.Addr = applyDefaultHost(cfg.Metrics.Addr, host) + cfg.Metrics.Addr = ApplyDefaultHost(cfg.Metrics.Addr, host) } if cfg.Profiling != nil { - cfg.Profiling.Addr = applyDefaultHost(cfg.Profiling.Addr, host) + cfg.Profiling.Addr = ApplyDefaultHost(cfg.Profiling.Addr, host) } } diff --git a/internal/gostx/config/parsing/parser/host_test.go b/internal/gostx/config/parsing/parser/host_test.go index 35fbc747..b9542545 100644 --- a/internal/gostx/config/parsing/parser/host_test.go +++ b/internal/gostx/config/parsing/parser/host_test.go @@ -1,11 +1,53 @@ package parser import ( + "net" "testing" "github.com/greyhavenhq/greyproxy/internal/gostx/config" ) +// TestNetListenWithDefault verifies the OS actually treats the normalized +// address as loopback-only on the test platform. Runs on every supported +// OS in CI (linux, darwin) and guards against the normalization output +// being syntactically right but semantically wrong (e.g. a typo that +// makes net.Listen bind everywhere). +func TestNetListenWithDefault(t *testing.T) { + addr := ApplyDefaultHost(":0", DefaultHost) + ln, err := net.Listen("tcp", addr) + if err != nil { + t.Fatalf("net.Listen(%q): %v", addr, err) + } + defer ln.Close() + + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("listener addr %T not *net.TCPAddr", ln.Addr()) + } + if !tcpAddr.IP.IsLoopback() { + t.Errorf("listener IP %v is not loopback", tcpAddr.IP) + } +} + +// TestNetListenWithUnspecified verifies --host 0.0.0.0 actually produces +// an unspecified bind so the WARN log corresponds to reality. +func TestNetListenWithUnspecified(t *testing.T) { + addr := ApplyDefaultHost(":0", "0.0.0.0") + ln, err := net.Listen("tcp", addr) + if err != nil { + t.Fatalf("net.Listen(%q): %v", addr, err) + } + defer ln.Close() + + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("listener addr %T not *net.TCPAddr", ln.Addr()) + } + if !tcpAddr.IP.IsUnspecified() { + t.Errorf("listener IP %v is not unspecified", tcpAddr.IP) + } +} + func TestApplyDefaultHost(t *testing.T) { tests := []struct { name string @@ -27,9 +69,9 @@ func TestApplyDefaultHost(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := applyDefaultHost(tc.addr, tc.host) + got := ApplyDefaultHost(tc.addr, tc.host) if got != tc.want { - t.Errorf("applyDefaultHost(%q, %q) = %q, want %q", tc.addr, tc.host, got, tc.want) + t.Errorf("ApplyDefaultHost(%q, %q) = %q, want %q", tc.addr, tc.host, got, tc.want) } }) } @@ -72,6 +114,84 @@ func TestWalkAddressesNilSections(t *testing.T) { } } +// TestParseAppliesDefaultHost walks a full YAML config through Parse() to +// confirm bare ":PORT" addrs get rewritten end-to-end. This catches wiring +// regressions that the standalone walkAddresses test wouldn't. +func TestParseAppliesDefaultHost(t *testing.T) { + yaml := []byte(` +host: 127.0.0.1 +services: + - name: http-proxy + addr: ":43051" + - name: explicit + addr: "0.0.0.0:43052" +metrics: + addr: ":9100" +profiling: + addr: ":6060" +`) + + Init(Args{DefaultConfig: yaml}) + cfg, err := Parse() + if err != nil { + t.Fatalf("Parse: %v", err) + } + + want := map[string]string{ + "http-proxy": "127.0.0.1:43051", + "explicit": "0.0.0.0:43052", + } + for _, svc := range cfg.Services { + if got := want[svc.Name]; got != svc.Addr { + t.Errorf("service %q: addr = %q, want %q", svc.Name, svc.Addr, got) + } + } + if cfg.Metrics.Addr != "127.0.0.1:9100" { + t.Errorf("metrics addr = %q, want 127.0.0.1:9100", cfg.Metrics.Addr) + } + if cfg.Profiling.Addr != "127.0.0.1:6060" { + t.Errorf("profiling addr = %q, want 127.0.0.1:6060", cfg.Profiling.Addr) + } +} + +// TestParseFlagOverridesYAMLHost confirms CLI --host wins over the YAML field. +func TestParseFlagOverridesYAMLHost(t *testing.T) { + yaml := []byte(` +host: 127.0.0.1 +services: + - name: http-proxy + addr: ":43051" +`) + + Init(Args{DefaultConfig: yaml, Host: "0.0.0.0"}) + cfg, err := Parse() + if err != nil { + t.Fatalf("Parse: %v", err) + } + if cfg.Services[0].Addr != "0.0.0.0:43051" { + t.Errorf("flag override: got %q, want 0.0.0.0:43051", cfg.Services[0].Addr) + } +} + +// TestParseDefaultsToLoopback confirms the built-in default when neither +// the flag nor the YAML field set a host. +func TestParseDefaultsToLoopback(t *testing.T) { + yaml := []byte(` +services: + - name: http-proxy + addr: ":43051" +`) + + Init(Args{DefaultConfig: yaml}) + cfg, err := Parse() + if err != nil { + t.Fatalf("Parse: %v", err) + } + if cfg.Services[0].Addr != "127.0.0.1:43051" { + t.Errorf("default: got %q, want 127.0.0.1:43051", cfg.Services[0].Addr) + } +} + func TestParseHostFlag(t *testing.T) { tests := []struct { name string diff --git a/internal/gostx/config/parsing/parser/parser.go b/internal/gostx/config/parsing/parser/parser.go index 3c2b87fb..f304752a 100644 --- a/internal/gostx/config/parsing/parser/parser.go +++ b/internal/gostx/config/parsing/parser/parser.go @@ -36,6 +36,12 @@ type Args struct { Debug bool Trace bool MetricsAddr string + // Host overrides the bind interface for every listener that was + // specified as a bare port (":43080" / "43080"). When empty, the + // top-level `host:` field from the config is used; if that's empty + // too, DefaultHost (127.0.0.1) wins. Explicit hosts in YAML or in + // -L addresses are left alone. + Host string } type parser struct { @@ -141,9 +147,23 @@ func (p *parser) Parse() (*config.Config, error) { } } + walkAddresses(cfg, ResolveHost(p.args.Host, cfg.Host)) + return cfg, nil } +// ResolveHost returns the host that should be applied to bare listener +// addresses. Precedence: CLI flag > YAML top-level `host:` > DefaultHost. +func ResolveHost(flagHost, yamlHost string) string { + if flagHost != "" { + return flagHost + } + if yamlHost != "" { + return yamlHost + } + return DefaultHost +} + func mergeConfig(cfg1, cfg2 *config.Config) *config.Config { if cfg1 == nil { return cfg2 @@ -153,6 +173,7 @@ func mergeConfig(cfg1, cfg2 *config.Config) *config.Config { } cfg := &config.Config{ + Host: cfg1.Host, Services: append(cfg1.Services, cfg2.Services...), Authers: append(cfg1.Authers, cfg2.Authers...), Admissions: append(cfg1.Admissions, cfg2.Admissions...), @@ -168,6 +189,9 @@ func mergeConfig(cfg1, cfg2 *config.Config) *config.Config { Metrics: cfg1.Metrics, Profiling: cfg1.Profiling, } + if cfg2.Host != "" { + cfg.Host = cfg2.Host + } if cfg2.TLS != nil { cfg.TLS = cfg2.TLS } From 19a6cb5ba41610c5a858ea1a30a8a9569e8d971b Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Wed, 20 May 2026 19:47:16 -0600 Subject: [PATCH 3/4] feat(cmd): add --host flag, normalize dashboard / profiling addrs, WARN on unspecified bind - main.go registers --host as a top-level serve flag; rejects hostnames (IP literal only) so operators don't have to wonder which resolved address the listener picked. - program.go applies the resolved host to the dashboard address (read through viper) and to the profiling fallback (":6060"), since those paths sit outside the parser's cfg.Services walk. - warnIfUnspecifiedBind logs a single warning at startup whenever the resolved host is 0.0.0.0 / ::, so the choice is visible in logs. --- cmd/greyproxy/main.go | 8 ++++++++ cmd/greyproxy/program.go | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/cmd/greyproxy/main.go b/cmd/greyproxy/main.go index a5859d9c..985b83f9 100644 --- a/cmd/greyproxy/main.go +++ b/cmd/greyproxy/main.go @@ -13,6 +13,7 @@ import ( "sync" "github.com/greyhavenhq/greyproxy/internal/gostcore/logger" + "github.com/greyhavenhq/greyproxy/internal/gostx/config/parsing/parser" xlogger "github.com/greyhavenhq/greyproxy/internal/gostx/logger" "github.com/kardianos/service" ) @@ -38,6 +39,7 @@ var ( silentAllow bool middlewareURLFlags stringList middlewareCmdFlags stringList + hostFlag string ) func init() { @@ -98,8 +100,14 @@ func parseFlags() { flag.BoolVar(&silentAllow, "silent-allow", false, "activate silent allow-all mode until restart") flag.Var(&middlewareURLFlags, "middleware", "middleware service URL (ws:// or http://); repeatable, cascades in declaration order") flag.Var(&middlewareCmdFlags, "middleware-cmd", "command to spawn as a stdio middleware (e.g. 'uv run mw.py'); repeatable, cascades after --middleware entries") + flag.StringVar(&hostFlag, "host", "", "default bind interface for listeners written as a bare port (e.g. \":43080\"); IP literal only, defaults to 127.0.0.1") flag.Parse() + if _, err := parser.ParseHostFlag(hostFlag); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "--host: %v\n", err) + os.Exit(2) + } + // Normalize http(s):// to ws(s):// for each middleware URL for i, u := range middlewareURLFlags { switch { diff --git a/cmd/greyproxy/program.go b/cmd/greyproxy/program.go index ba471ed2..f72746f2 100644 --- a/cmd/greyproxy/program.go +++ b/cmd/greyproxy/program.go @@ -66,6 +66,7 @@ func (p *program) initParser() { Debug: debug, Trace: trace, MetricsAddr: metricsAddr, + Host: hostFlag, }) } @@ -82,6 +83,8 @@ func (p *program) Start(s service.Service) error { os.Exit(0) } + warnIfUnspecifiedBind(parser.ResolveHost(hostFlag, viper.GetString("host"))) + // Auto-inject MITM cert paths if CA files exist injectCertPaths(cfg, greyproxyDataHome()) @@ -272,6 +275,7 @@ func (p *program) run(cfg *config.Config) error { if addr == "" { addr = ":6060" } + addr = parser.ApplyDefaultHost(addr, parser.ResolveHost(hostFlag, viper.GetString("host"))) s := &http.Server{ Addr: addr, } @@ -410,6 +414,13 @@ func (p *program) buildGreyproxyService() error { gaCfg.Resolver = "resolver-0" } + // Normalize the dashboard bind address. The service / metrics / profiling + // addrs are normalized inside parser.Parse() via walkAddresses, but the + // greyproxy block lives under a viper subtree and is unmarshalled here, + // so it needs its own pass against the same resolved host. + resolvedHost := parser.ResolveHost(hostFlag, viper.GetString("host")) + gaCfg.Addr = parser.ApplyDefaultHost(gaCfg.Addr, resolvedHost) + applyDockerEnvOverrides(&gaCfg) log := logger.Default().WithFields(map[string]any{"kind": "service", "service": "@greyproxy"}) @@ -1173,6 +1184,20 @@ func decompressWebSocketFrame(payload []byte) ([]byte, error) { return io.ReadAll(r) } +// warnIfUnspecifiedBind logs once at startup when the resolved bind host is an +// unspecified address (0.0.0.0 or ::). Operators see the warning in logs and +// can confirm the choice was intentional. +func warnIfUnspecifiedBind(host string) { + ip := net.ParseIP(host) + if ip == nil || !ip.IsUnspecified() { + return + } + logger.Default().Warnf( + "binding listeners to all interfaces (host=%s); proxy and dashboard will be reachable from any network — see SECURITY.md", + host, + ) +} + // applyDockerEnvOverrides configures Docker resolution from environment variables. // Docker is disabled by default; use these env vars to opt in: // From de26ec00b29ca9b59a3619481290768ab47d56f4 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Wed, 20 May 2026 19:47:25 -0600 Subject: [PATCH 4/4] docs: bind to localhost by default, document host: / --host - greyproxy.yml (the embedded default) now sets host: 127.0.0.1 explicitly. - docs/configuration.md gains a Bind Interface section explaining the precedence order (flag > YAML > built-in default). - docs/cli-reference.md documents --host and notes installed services bind to loopback (operators edit the unit/plist for LAN exposure). - SECURITY.md replaces the stock GitHub template with a Default Security Posture section listing the four ports + how to opt into a wider bind. --- SECURITY.md | 48 ++++++++++++++++++++++++++++++------------- docs/cli-reference.md | 3 ++- docs/configuration.md | 26 +++++++++++++++++++++++ greyproxy.yml | 6 ++++++ 4 files changed, 68 insertions(+), 15 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 034e8480..851b4d98 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,21 +1,41 @@ # Security Policy -## Supported Versions +## Reporting a Vulnerability -Use this section to tell people about which versions of your project are -currently being supported with security updates. +Please report security issues privately via GitHub's "Report a vulnerability" +button on the Security tab of this repository. We aim to acknowledge new +reports within 5 business days. -| Version | Supported | -| ------- | ------------------ | -| 5.1.x | :white_check_mark: | -| 5.0.x | :x: | -| 4.0.x | :white_check_mark: | -| < 4.0 | :x: | +## Default Security Posture -## Reporting a Vulnerability +Greyproxy is designed to run on the same host as the workloads it proxies. By +default it binds **only to the loopback interface (`127.0.0.1`)** on every +port: + +| Service | Default bind | +|---------------|---------------------| +| Dashboard/API | `127.0.0.1:43080` | +| HTTP Proxy | `127.0.0.1:43051` | +| SOCKS5 Proxy | `127.0.0.1:43052` | +| DNS Proxy | `127.0.0.1:43053` | + +This prevents the dashboard, REST API, and proxy ports from being reachable +from other machines on the network without explicit operator action — the +proxies cannot be abused as an open relay, the DNS resolver cannot be used +for amplification, and the management API cannot be reached by other hosts. -Use this section to tell people how to report a vulnerability. +To expose greyproxy on a network interface, either: + +- pass `--host ` to `greyproxy serve` (IP literal only; hostnames are + rejected), or +- set the top-level `host:` field in the config file. + +The CLI flag wins over the YAML field. Explicit hosts in individual `addr:` +entries (e.g. `addr: "0.0.0.0:43080"`) are honoured as-is. + +When the resolved host is an unspecified address (`0.0.0.0` or `::`), +greyproxy logs a warning at startup so the choice is visible in the logs. + +## Supported Versions -Tell them where to go, how often they can expect to get an update on a -reported vulnerability, what to expect if the vulnerability is accepted or -declined, etc. +Security fixes target the latest tagged release. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 9ec64b1d..16cb29bf 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -34,6 +34,7 @@ greyproxy serve -C greyproxy.yml | `-O ` | Dump the resolved config and exit. One of `yaml` or `json`. | | `-metrics ` | Expose Prometheus-style metrics on the given address. | | `-silent-allow` | Enter silent allow-all mode at startup. Requests pass through without prompting until the process restarts. | +| `--host ` | Default bind interface for listeners written as a bare port (e.g. `:43080`). IP literal only (hostnames are rejected). Defaults to `127.0.0.1`. Pass `--host 0.0.0.0` to bind every interface — the choice is logged with a warning at startup. | | `-V` | Print version information and exit. | ### `greyproxy cert` @@ -71,7 +72,7 @@ greyproxy install -f # skip confirmation prompts |------|-------------| | `-f`, `--force` | Skip interactive confirmation prompts. | -The dashboard is available at [http://localhost:43080](http://localhost:43080) once the service is running. +The dashboard is available at [http://localhost:43080](http://localhost:43080) once the service is running. The installed service binds to loopback only; to expose it on a network interface, edit the systemd unit (Linux) or launchd plist (macOS) to add `--host ` to the `serve` command line. ### `greyproxy uninstall` diff --git a/docs/configuration.md b/docs/configuration.md index 4cac7b49..d32020fc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,6 +16,11 @@ The config format is inherited from [GOST v3](https://gost.run/en/); greyproxy a ## Example Configuration ```yaml +# Default bind interface for any listener written as a bare port (":43080"). +# Loopback by default so the proxy and dashboard aren't reachable from other +# machines. See "Bind Interface" below for details. +host: 127.0.0.1 + log: level: info format: json @@ -84,6 +89,27 @@ services: resolver: resolver-0 ``` +## Bind Interface + +By default greyproxy binds **only to the loopback interface** (`127.0.0.1`). +Any listener whose address is a bare port (`":43080"`, `"43080"`) is rewritten +to `127.0.0.1:` at startup. Addresses that already carry an explicit +host (`"0.0.0.0:43080"`, `"192.168.1.10:43080"`, `"[::1]:43080"`) are left +alone. + +Override the default at the top of the config file: + +```yaml +host: 0.0.0.0 # bind to every interface +``` + +Or pass `--host ` to `greyproxy serve` (IP literal only; hostnames are +rejected). The CLI flag wins over the YAML field, which wins over the +built-in `127.0.0.1` default. + +When the resolved host is unspecified (`0.0.0.0` or `::`), greyproxy logs a +warning at startup so the operator can confirm the choice was deliberate. + ## The `greyproxy` Block | Field | Type | Description | diff --git a/greyproxy.yml b/greyproxy.yml index 40b35029..da9d6673 100644 --- a/greyproxy.yml +++ b/greyproxy.yml @@ -6,6 +6,12 @@ # NOTE: This is a template file. The actual gost.yaml is generated # by entrypoint-gost.sh which adds custom hosts from PROXY_CUSTOM_HOSTS. +# Default bind interface for any listener written as a bare port (":43080"). +# Loopback by default so the proxy and dashboard aren't reachable from other +# machines. Override with `greyproxy serve --host ` (IP literal only) or +# by editing this field. See SECURITY.md for the rationale. +host: 127.0.0.1 + log: level: info format: json