diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ed87f..ac347af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.2.5 2026-08-24 + +### Changed + +- Entrypoints bind IPv4 and IPv6 loopback addresses by default, preventing LAN exposure while supporting both localhost stacks. +- Entrypoint `listen` accepts one address or a list; legacy wildcard addresses now resolve to both loopback addresses. +- **Breaking:** Entrypoints accept loopback IP addresses only. Replace LAN addresses with `127.0.0.1:port` and `[::1]:port`. + ## 0.2.4 2026-08-20 ### Fixed diff --git a/README.md b/README.md index b1396c5..e289ce9 100644 --- a/README.md +++ b/README.md @@ -230,34 +230,46 @@ dns: upstream: "8.8.8.8:53" # Entrypoints define the ports devproxy listens on +# Entrypoints are restricted to IPv4 and IPv6 loopback addresses # Reserved names: "http" and "https" are handled specially entrypoints: # HTTP entrypoint (redirects to HTTPS by default) http: - listen: ":80" + listen: + - "127.0.0.1:80" + - "[::1]:80" # HTTPS entrypoint (TLS termination with auto-generated certs) https: - listen: ":443" + listen: + - "127.0.0.1:443" + - "[::1]:443" # TCP entrypoints for databases and other services # The name is used in container labels: devproxy.entrypoint=postgres # Note: Only postgres and mongo are included by default postgres: - listen: ":15432" # Port devproxy listens on + listen: + - "127.0.0.1:15432" + - "[::1]:15432" target_port: 5432 # Default backend port (optional) mongo: - listen: ":27017" + listen: + - "127.0.0.1:27017" + - "[::1]:27017" target_port: 27017 - # Additional entrypoints can be added as needed: + # Additional entrypoints can be added as needed. A single string remains valid; + # legacy wildcard strings such as ":13306" are restricted to both loopback addresses: # mysql: - # listen: ":13306" + # listen: "127.0.0.1:13306" # target_port: 3306 # # redis: - # listen: ":16379" + # listen: + # - "127.0.0.1:16379" + # - "[::1]:16379" # target_port: 6379 # Docker integration settings @@ -287,11 +299,11 @@ If no configuration file exists, devproxy creates one with these defaults: | `dns.listen` | `:15353` | | `dns.domains` | `["localhost"]` | | `dns.upstream` | `8.8.8.8:53` | -| `entrypoints.http.listen` | `:80` | -| `entrypoints.https.listen` | `:443` | -| `entrypoints.postgres.listen` | `:15432` | +| `entrypoints.http.listen` | `["127.0.0.1:80", "[::1]:80"]` | +| `entrypoints.https.listen` | `["127.0.0.1:443", "[::1]:443"]` | +| `entrypoints.postgres.listen` | `["127.0.0.1:15432", "[::1]:15432"]` | | `entrypoints.postgres.target_port` | `5432` | -| `entrypoints.mongo.listen` | `:27017` | +| `entrypoints.mongo.listen` | `["127.0.0.1:27017", "[::1]:27017"]` | | `entrypoints.mongo.target_port` | `27017` | | `docker.enabled` | `true` | | `docker.socket` | `unix:///var/run/docker.sock` | diff --git a/cmd/devproxy/cmd/run.go b/cmd/devproxy/cmd/run.go index 0775624..3d4947c 100644 --- a/cmd/devproxy/cmd/run.go +++ b/cmd/devproxy/cmd/run.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "syscall" "github.com/spf13/cobra" @@ -63,6 +64,37 @@ func init() { rootCmd.AddCommand(runCmd, initConfigCmd) } +func listenAll(addresses []string) ([]net.Listener, error) { + return listenAllWith(addresses, net.Listen) +} + +func listenAllWith(addresses []string, listen func(string, string) (net.Listener, error)) ([]net.Listener, error) { + listeners := make([]net.Listener, 0, len(addresses)) + var unavailableErr error + for _, address := range addresses { + listener, err := listen("tcp", address) + if err != nil { + if errors.Is(err, syscall.EAFNOSUPPORT) || errors.Is(err, syscall.EADDRNOTAVAIL) { + unavailableErr = fmt.Errorf("failed to listen on %s: %w", address, err) + continue + } + closeListeners(listeners) + return nil, fmt.Errorf("failed to listen on %s: %w", address, err) + } + listeners = append(listeners, listener) + } + if len(listeners) == 0 && unavailableErr != nil { + return nil, unavailableErr + } + return listeners, nil +} + +func closeListeners(listeners []net.Listener) { + for _, listener := range listeners { + _ = listener.Close() + } +} + func loadConfig(originalUser *privilege.Info) (*config.Config, error) { if originalUser == nil { return config.Load() @@ -109,51 +141,46 @@ func runDaemon() error { httpCfg, _ := cfg.GetEntrypoint("http") httpsCfg, _ := cfg.GetEntrypoint("https") - // Bind HTTP port - httpListener, err := net.Listen("tcp", httpCfg.Listen) + // Bind HTTP ports + httpListeners, err := listenAll(httpCfg.Listen) if err != nil { - return fmt.Errorf("failed to bind HTTP port %s: %w", httpCfg.Listen, err) + return fmt.Errorf("failed to bind HTTP ports: %w", err) } + defer closeListeners(httpListeners) - // Bind HTTPS port - httpsListener, err := net.Listen("tcp", httpsCfg.Listen) + // Bind HTTPS ports + httpsListeners, err := listenAll(httpsCfg.Listen) if err != nil { - httpListener.Close() - return fmt.Errorf("failed to bind HTTPS port %s: %w", httpsCfg.Listen, err) + return fmt.Errorf("failed to bind HTTPS ports: %w", err) } + defer closeListeners(httpsListeners) // Bind DNS port if enabled var dnsListener net.PacketConn if cfg.DNS.Enabled { dnsListener, err = net.ListenPacket("udp", cfg.DNS.Listen) if err != nil { - httpListener.Close() - httpsListener.Close() return fmt.Errorf("failed to bind DNS port %s: %w", cfg.DNS.Listen, err) } + defer dnsListener.Close() } // Bind TCP entrypoint ports - tcpListeners := make(map[string]net.Listener) + tcpListeners := make(map[string][]net.Listener) + var allTCPListeners []net.Listener for name, epCfg := range cfg.Entrypoints { if name == "http" || name == "https" || epCfg.TargetPort <= 0 { continue } - listener, err := net.Listen("tcp", epCfg.Listen) + listeners, err := listenAll(epCfg.Listen) if err != nil { - // Clean up already-bound listeners - httpListener.Close() - httpsListener.Close() - if dnsListener != nil { - dnsListener.Close() - } - for _, l := range tcpListeners { - l.Close() - } - return fmt.Errorf("failed to bind TCP entrypoint %s on %s: %w", name, epCfg.Listen, err) + closeListeners(allTCPListeners) + return fmt.Errorf("failed to bind TCP entrypoint %s: %w", name, err) } - tcpListeners[name] = listener + tcpListeners[name] = listeners + allTCPListeners = append(allTCPListeners, listeners...) } + defer closeListeners(allTCPListeners) // ========================================================================= // DROP PRIVILEGES - Run as original user after binding privileged ports @@ -175,15 +202,6 @@ func runDaemon() error { } if err := privilege.Drop(originalUser); err != nil { - // Clean up listeners before returning - httpListener.Close() - httpsListener.Close() - if dnsListener != nil { - dnsListener.Close() - } - for _, l := range tcpListeners { - l.Close() - } return fmt.Errorf("failed to drop privileges: %w", err) } // Log after dropping privileges (logging not set up yet) @@ -296,22 +314,28 @@ func runDaemon() error { // ========================================================================= // Extract HTTPS port for redirect httpsPort := 443 - if _, portStr, err := net.SplitHostPort(httpsCfg.Listen); err == nil { + if _, portStr, err := net.SplitHostPort(httpsCfg.Listen[0]); err == nil { if p, err := net.LookupPort("tcp", portStr); err == nil { httpsPort = p } } - httpServer := proxy.NewHTTPServerWithListener(httpListener, httpsPort) - if err := httpServer.Start(); err != nil { - return fmt.Errorf("failed to start HTTP server: %w", err) + httpServers := make([]*proxy.HTTPServer, 0, len(httpListeners)) + for _, listener := range httpListeners { + httpServer := proxy.NewHTTPServerWithListener(listener, httpsPort) + if err := httpServer.Start(); err != nil { + return fmt.Errorf("failed to start HTTP server: %w", err) + } + httpServers = append(httpServers, httpServer) } shutdown.OnShutdown(func() { - if err := httpServer.Stop(); err != nil { - logging.Error("failed to stop HTTP server", "error", err) + for _, server := range httpServers { + if err := server.Stop(); err != nil { + logging.Error("failed to stop HTTP server", "error", err) + } } }) - logging.Info("HTTP server started", "address", httpCfg.Listen) + logging.Info("HTTP servers started", "addresses", httpCfg.Listen) // ========================================================================= // Start HTTPS Server (using pre-bound listener) @@ -324,16 +348,22 @@ func runDaemon() error { httpsHandler := proxy.NewAccessLogger(proxyHandler, slog.Default(), func() bool { return (*cfgPtr).Logging.AccessLog }) - httpsServer := proxy.NewHTTPSServerWithListener(httpsListener, certManager, httpsHandler) - if err := httpsServer.Start(); err != nil { - return fmt.Errorf("failed to start HTTPS server: %w", err) + httpsServers := make([]*proxy.HTTPSServer, 0, len(httpsListeners)) + for _, listener := range httpsListeners { + httpsServer := proxy.NewHTTPSServerWithListener(listener, certManager, httpsHandler) + if err := httpsServer.Start(); err != nil { + return fmt.Errorf("failed to start HTTPS server: %w", err) + } + httpsServers = append(httpsServers, httpsServer) } shutdown.OnShutdown(func() { - if err := httpsServer.Stop(); err != nil { - logging.Error("failed to stop HTTPS server", "error", err) + for _, server := range httpsServers { + if err := server.Stop(); err != nil { + logging.Error("failed to stop HTTPS server", "error", err) + } } }) - logging.Info("HTTPS server started", "address", httpsCfg.Listen) + logging.Info("HTTPS servers started", "addresses", httpsCfg.Listen) // ========================================================================= // Start TCP Entrypoints (using pre-bound listeners) @@ -350,28 +380,26 @@ func runDaemon() error { continue } - listener, ok := tcpListeners[name] - if !ok { - continue - } + listeners := tcpListeners[name] + for _, listener := range listeners { + tcpCfg := proxy.TCPEntrypointConfig{ + Name: name, + Listen: listener.Addr().String(), + TargetPort: epCfg.TargetPort, + Registry: registry, + CertManager: certManager, + Logger: logger, + } - tcpCfg := proxy.TCPEntrypointConfig{ - Name: name, - Listen: epCfg.Listen, - TargetPort: epCfg.TargetPort, - Registry: registry, - CertManager: certManager, - Logger: logger, - } + tcpEntry := proxy.NewTCPEntrypointWithListener(tcpCfg, listener) + if err := tcpEntry.Start(ctx); err != nil { + logging.Error("failed to start TCP entrypoint", "name", name, "error", err) + continue + } - tcpEntry := proxy.NewTCPEntrypointWithListener(tcpCfg, listener) - if err := tcpEntry.Start(ctx); err != nil { - logging.Error("failed to start TCP entrypoint", "name", name, "error", err) - continue + tcpEntrypoints = append(tcpEntrypoints, tcpEntry) } - - tcpEntrypoints = append(tcpEntrypoints, tcpEntry) - logging.Info("TCP entrypoint started", "name", name, "address", epCfg.Listen, "target_port", epCfg.TargetPort) + logging.Info("TCP entrypoint started", "name", name, "addresses", epCfg.Listen, "target_port", epCfg.TargetPort) } // Register TCP entrypoint cleanup @@ -481,7 +509,7 @@ func applyConfigChanges(oldCfg, newCfg *config.Config, dnsServer *dns.Server) { // Update DNS settings (domains and upstream only - listen address requires restart) if dnsServer != nil { - domainsChanged := !equalStringSlices(oldCfg.DNS.Domains, newCfg.DNS.Domains) + domainsChanged := !slices.Equal(oldCfg.DNS.Domains, newCfg.DNS.Domains) upstreamChanged := oldCfg.DNS.Upstream != newCfg.DNS.Upstream if domainsChanged || upstreamChanged { @@ -498,23 +526,10 @@ func applyConfigChanges(oldCfg, newCfg *config.Config, dnsServer *dns.Server) { // Check for entrypoint changes for name, oldEp := range oldCfg.Entrypoints { if newEp, exists := newCfg.Entrypoints[name]; exists { - if oldEp.Listen != newEp.Listen { + if !slices.Equal(oldEp.Listen, newEp.Listen) { logging.Warn("entrypoint listen address changed - restart required to apply", "entrypoint", name, "old", oldEp.Listen, "new", newEp.Listen) } } } } - -// equalStringSlices compares two string slices for equality. -func equalStringSlices(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i, v := range a { - if v != b[i] { - return false - } - } - return true -} diff --git a/cmd/devproxy/cmd/run_test.go b/cmd/devproxy/cmd/run_test.go index aa21add..39cecff 100644 --- a/cmd/devproxy/cmd/run_test.go +++ b/cmd/devproxy/cmd/run_test.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "net" "os" "path/filepath" "syscall" @@ -23,6 +24,51 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } +func TestListenAll(t *testing.T) { + probe, err := net.Listen("tcp6", "[::1]:0") + if err != nil { + t.Skipf("IPv6 loopback unavailable: %v", err) + } + probe.Close() + + listeners, err := listenAll([]string{"127.0.0.1:0", "[::1]:0"}) + if err != nil { + t.Fatal(err) + } + defer closeListeners(listeners) + + if len(listeners) != 2 { + t.Fatalf("len(listeners) = %d, want 2", len(listeners)) + } + for _, listener := range listeners { + if !listener.Addr().(*net.TCPAddr).IP.IsLoopback() { + t.Errorf("listener address %s is not loopback", listener.Addr()) + } + } +} + +func TestListenAllSkipsUnavailableFamily(t *testing.T) { + listen := func(network, address string) (net.Listener, error) { + if address == "[::1]:0" { + return nil, syscall.EAFNOSUPPORT + } + return net.Listen(network, address) + } + + listeners, err := listenAllWith([]string{"[::1]:0", "127.0.0.1:0"}, listen) + if err != nil { + t.Fatal(err) + } + defer closeListeners(listeners) + if len(listeners) != 1 { + t.Fatalf("len(listeners) = %d, want 1", len(listeners)) + } + + if _, err := listenAllWith([]string{"[::1]:0"}, listen); err == nil { + t.Fatal("listenAllWith() error = nil, want unavailable address error") + } +} + func TestChownRecursive(t *testing.T) { t.Run("returns error for missing path", func(t *testing.T) { missingPath := filepath.Join(t.TempDir(), "missing") diff --git a/cmd/devproxy/cmd/status.go b/cmd/devproxy/cmd/status.go index d939ee2..a5a5981 100644 --- a/cmd/devproxy/cmd/status.go +++ b/cmd/devproxy/cmd/status.go @@ -98,12 +98,12 @@ func getStatus() Status { if ep, ok := cfg.Entrypoints["http"]; ok { status.Entrypoints = append(status.Entrypoints, - Entrypoint{Name: "http", Listen: ep.Listen, Protocol: "HTTP", Status: getListenerStatus(status.Running)}) + Entrypoint{Name: "http", Listen: strings.Join(ep.Listen, ", "), Protocol: "HTTP", Status: getListenerStatus(status.Running)}) } if ep, ok := cfg.Entrypoints["https"]; ok { status.Entrypoints = append(status.Entrypoints, - Entrypoint{Name: "https", Listen: ep.Listen, Protocol: "HTTPS", Status: getListenerStatus(status.Running)}) + Entrypoint{Name: "https", Listen: strings.Join(ep.Listen, ", "), Protocol: "HTTPS", Status: getListenerStatus(status.Running)}) } // Add TCP entrypoints @@ -113,7 +113,7 @@ func getStatus() Status { } if ep.TargetPort > 0 { status.Entrypoints = append(status.Entrypoints, - Entrypoint{Name: name, Listen: ep.Listen, Protocol: "TCP", Status: getListenerStatus(status.Running)}) + Entrypoint{Name: name, Listen: strings.Join(ep.Listen, ", "), Protocol: "TCP", Status: getListenerStatus(status.Running)}) } } diff --git a/internal/config/config.go b/internal/config/config.go index c6d1ded..7ea4afa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,8 +3,11 @@ package config import ( "fmt" + "net" "os" "path/filepath" + "slices" + "strings" "gopkg.in/yaml.v3" @@ -27,10 +30,54 @@ type DNSConfig struct { Upstream string `yaml:"upstream"` } +// ListenAddresses is one or more addresses for an entrypoint. +type ListenAddresses []string + +// UnmarshalYAML accepts either a single address or a list of addresses. +func (a *ListenAddresses) UnmarshalYAML(value *yaml.Node) error { + var addresses []string + switch value.Kind { + case yaml.ScalarNode: + var address string + if err := value.Decode(&address); err != nil { + return err + } + addresses = []string{address} + case yaml.SequenceNode: + if err := value.Decode(&addresses); err != nil { + return err + } + default: + return fmt.Errorf("listen must be a string or list of strings") + } + + *a = nil + for _, address := range addresses { + for _, normalized := range loopbackAddresses(address) { + if !slices.Contains(*a, normalized) { + *a = append(*a, normalized) + } + } + } + return nil +} + +func loopbackAddresses(address string) ListenAddresses { + host, port, err := net.SplitHostPort(address) + if err != nil { + return ListenAddresses{address} + } + ip := net.ParseIP(host) + if host == "" || strings.EqualFold(strings.TrimSuffix(host, "."), "localhost") || ip != nil && ip.IsUnspecified() { + return ListenAddresses{net.JoinHostPort("127.0.0.1", port), net.JoinHostPort("::1", port)} + } + return ListenAddresses{address} +} + // EntrypointConfig configures a single entrypoint (HTTP, HTTPS, or TCP). type EntrypointConfig struct { - Listen string `yaml:"listen"` - TargetPort int `yaml:"target_port,omitempty"` + Listen ListenAddresses `yaml:"listen"` + TargetPort int `yaml:"target_port,omitempty"` } // DockerConfig configures Docker integration. @@ -58,17 +105,17 @@ func Default() *Config { }, Entrypoints: map[string]EntrypointConfig{ "http": { - Listen: ":80", // Privileged port (requires root) + Listen: ListenAddresses{"127.0.0.1:80", "[::1]:80"}, // Privileged port (requires root) }, "https": { - Listen: ":443", // Privileged port (requires root) + Listen: ListenAddresses{"127.0.0.1:443", "[::1]:443"}, // Privileged port (requires root) }, "postgres": { - Listen: ":15432", + Listen: ListenAddresses{"127.0.0.1:15432", "[::1]:15432"}, TargetPort: 5432, }, "mongo": { - Listen: ":27017", + Listen: ListenAddresses{"127.0.0.1:27017", "[::1]:27017"}, TargetPort: 27017, }, }, @@ -168,9 +215,31 @@ func (c *Config) Validate() error { return fmt.Errorf("at least one entrypoint is required") } for name, ep := range c.Entrypoints { - if ep.Listen == "" { + if len(ep.Listen) == 0 { return fmt.Errorf("entrypoint %q: listen address is required", name) } + port := -1 + for _, address := range ep.Listen { + if address == "" { + return fmt.Errorf("entrypoint %q: listen address is required", name) + } + host, portString, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("entrypoint %q: invalid listen address %q: %w", name, address, err) + } + addressPort, err := net.LookupPort("tcp", portString) + if err != nil { + return fmt.Errorf("entrypoint %q: invalid listen port %q: %w", name, portString, err) + } + if port != -1 && port != addressPort { + return fmt.Errorf("entrypoint %q: listen addresses must use the same port", name) + } + port = addressPort + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return fmt.Errorf("entrypoint %q: listen address %q must use a loopback IP", name, address) + } + } } // Validate Docker config diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5ef933a..47f6898 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "slices" "testing" ) @@ -26,23 +27,23 @@ func TestDefault(t *testing.T) { } http, ok := cfg.Entrypoints["http"] - if !ok || http.Listen != ":80" { - t.Errorf("Entrypoints[http] = %+v, want Listen=:80", http) + if !ok || !slices.Equal(http.Listen, ListenAddresses{"127.0.0.1:80", "[::1]:80"}) { + t.Errorf("Entrypoints[http] = %+v, want dual-stack loopback listeners", http) } https, ok := cfg.Entrypoints["https"] - if !ok || https.Listen != ":443" { - t.Errorf("Entrypoints[https] = %+v, want Listen=:443", https) + if !ok || !slices.Equal(https.Listen, ListenAddresses{"127.0.0.1:443", "[::1]:443"}) { + t.Errorf("Entrypoints[https] = %+v, want dual-stack loopback listeners", https) } postgres, ok := cfg.Entrypoints["postgres"] - if !ok || postgres.Listen != ":15432" || postgres.TargetPort != 5432 { - t.Errorf("Entrypoints[postgres] = %+v, want Listen=:15432, TargetPort=5432", postgres) + if !ok || !slices.Equal(postgres.Listen, ListenAddresses{"127.0.0.1:15432", "[::1]:15432"}) || postgres.TargetPort != 5432 { + t.Errorf("Entrypoints[postgres] = %+v, want dual-stack loopback listeners and TargetPort=5432", postgres) } mongo, ok := cfg.Entrypoints["mongo"] - if !ok || mongo.Listen != ":27017" || mongo.TargetPort != 27017 { - t.Errorf("Entrypoints[mongo] = %+v, want Listen=:27017, TargetPort=27017", mongo) + if !ok || !slices.Equal(mongo.Listen, ListenAddresses{"127.0.0.1:27017", "[::1]:27017"}) || mongo.TargetPort != 27017 { + t.Errorf("Entrypoints[mongo] = %+v, want dual-stack loopback listeners and TargetPort=27017", mongo) } // Docker defaults @@ -93,6 +94,20 @@ func TestValidate(t *testing.T) { modify: func(c *Config) { c.Entrypoints["test"] = EntrypointConfig{} }, wantErr: true, }, + { + name: "entrypoint on LAN address", + modify: func(c *Config) { + c.Entrypoints["http"] = EntrypointConfig{Listen: ListenAddresses{"192.168.1.10:80"}} + }, + wantErr: true, + }, + { + name: "entrypoint with mixed ports", + modify: func(c *Config) { + c.Entrypoints["https"] = EntrypointConfig{Listen: ListenAddresses{"127.0.0.1:443", "[::1]:8443"}} + }, + wantErr: true, + }, { name: "docker enabled without socket", modify: func(c *Config) { c.Docker.Enabled = true; c.Docker.Socket = "" }, @@ -207,6 +222,38 @@ func TestLoadFromFile_CreatesDefault(t *testing.T) { } } +func TestLoadFromFile_ListenFormats(t *testing.T) { + tests := []struct { + name string + yaml string + want ListenAddresses + }{ + {name: "single string", yaml: `"127.0.0.1:8080"`, want: ListenAddresses{"127.0.0.1:8080"}}, + {name: "localhost", yaml: `"LOCALHOST.:8080"`, want: ListenAddresses{"127.0.0.1:8080", "[::1]:8080"}}, + {name: "legacy wildcard", yaml: `":8080"`, want: ListenAddresses{"127.0.0.1:8080", "[::1]:8080"}}, + {name: "address list", yaml: `["127.0.0.1:8080", "[::1]:8080"]`, want: ListenAddresses{"127.0.0.1:8080", "[::1]:8080"}}, + {name: "duplicate wildcards", yaml: `["0.0.0.0:8080", "[::]:8080"]`, want: ListenAddresses{"127.0.0.1:8080", "[::1]:8080"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.yaml") + data := []byte("entrypoints:\n http:\n listen: " + tt.yaml + "\n") + if err := os.WriteFile(configPath, data, 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := LoadFromFile(configPath) + if err != nil { + t.Fatal(err) + } + if got := cfg.Entrypoints["http"].Listen; !slices.Equal(got, tt.want) { + t.Errorf("Listen = %v, want %v", got, tt.want) + } + }) + } +} + func TestLoadFromFile_InvalidYAML(t *testing.T) { // Create temp directory tmpDir, err := os.MkdirTemp("", "devproxy-config-test") @@ -263,8 +310,9 @@ func TestGetEntrypoint(t *testing.T) { if !ok { t.Error("GetEntrypoint(postgres) returned false, want true") } - if ep.Listen != ":15432" { - t.Errorf("GetEntrypoint(postgres).Listen = %q, want %q", ep.Listen, ":15432") + want := ListenAddresses{"127.0.0.1:15432", "[::1]:15432"} + if !slices.Equal(ep.Listen, want) { + t.Errorf("GetEntrypoint(postgres).Listen = %v, want %v", ep.Listen, want) } // Non-existent entrypoint diff --git a/internal/proxy/http.go b/internal/proxy/http.go index 851fa85..05e649b 100644 --- a/internal/proxy/http.go +++ b/internal/proxy/http.go @@ -7,6 +7,7 @@ import ( "log/slog" "net" "net/http" + "strings" "time" ) @@ -94,10 +95,12 @@ func (s *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { if h, _, err := net.SplitHostPort(host); err == nil { host = h } + host = strings.Trim(host, "[]") // Add HTTPS port if not the default 443 - if s.httpsPort != 443 { - host = fmt.Sprintf("%s:%d", host, s.httpsPort) + host = net.JoinHostPort(host, fmt.Sprint(s.httpsPort)) + if s.httpsPort == 443 { + host = strings.TrimSuffix(host, ":443") } // Build redirect URL diff --git a/internal/proxy/http_test.go b/internal/proxy/http_test.go index 238a3fe..3f637cd 100644 --- a/internal/proxy/http_test.go +++ b/internal/proxy/http_test.go @@ -44,6 +44,13 @@ func TestHTTPServer_RedirectToHTTPS(t *testing.T) { expectedURL: "https://app.localhost/", expectedStatus: http.StatusMovedPermanently, }, + { + name: "IPv6 host with port", + requestURL: "/", + host: "[::1]:80", + expectedURL: "https://[::1]/", + expectedStatus: http.StatusMovedPermanently, + }, } for _, tt := range tests { @@ -100,13 +107,13 @@ func TestHTTPServer_NonStandardHTTPSPort(t *testing.T) { server := NewHTTPServer("127.0.0.1:0", 8443) req := httptest.NewRequest(http.MethodGet, "/", nil) - req.Host = "app.localhost" + req.Host = "[::1]:80" w := httptest.NewRecorder() server.ServeHTTP(w, req) location := w.Header().Get("Location") - expected := "https://app.localhost:8443/" + expected := "https://[::1]:8443/" if location != expected { t.Errorf("expected redirect to %q, got %q", expected, location) }