From 2ab3b20bd42b3eb963df67bb173539bbc13bfba5 Mon Sep 17 00:00:00 2001 From: et-nik Date: Wed, 2 Sep 2026 16:12:46 +0200 Subject: [PATCH 1/2] https command fix --- README.md | 7 + internal/actions/panel/https/disable.go | 20 +- internal/actions/panel/https/enable.go | 52 ++- .../panel/https/https_internal_test.go | 138 ++++++++ internal/actions/panel/https/status.go | 33 +- .../actions/panel/update/panel_update_v4.go | 45 ++- internal/pkg/panel/install.go | 37 +- internal/pkg/panel/install_internal_test.go | 45 +++ pkg/panel/bindaddr.go | 207 +++++++++++ pkg/panel/bindaddr_test.go | 332 ++++++++++++++++++ pkg/panel/tls.go | 8 +- 11 files changed, 866 insertions(+), 58 deletions(-) create mode 100644 pkg/panel/bindaddr.go create mode 100644 pkg/panel/bindaddr_test.go diff --git a/README.md b/README.md index bd1656c..977afc6 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,13 @@ Both listeners are served by one process, and the panel exits when it cannot loa it is configured with. `enable` therefore verifies that the panel comes back up serving exactly the certificate it just wrote, and restores the previous `config.env` when it does not. +That check goes to the address the panel binds, not to loopback. The panel listens on +`HTTP_BIND_IP` when that is set, otherwise on `HTTP_HOST` when it holds an address of this machine, +and otherwise on every interface: `HTTP_HOST` empty, `0.0.0.0`, or a name that does not resolve to +one of the machine's own addresses. `enable`, `disable` and `status` all check it there and fall +back to loopback, so an installation configured with its public address is verified where it +actually answers. `status` prints the address it worked out. + | | system scope | user scope | |---|---|---| | Certificate | `/etc/gameap/certs/panel.crt` | `~/.config/gameap/certs/panel.crt` | diff --git a/internal/actions/panel/https/disable.go b/internal/actions/panel/https/disable.go index 3ac427f..2e545cb 100644 --- a/internal/actions/panel/https/disable.go +++ b/internal/actions/panel/https/disable.go @@ -4,6 +4,7 @@ import ( "context" "io/fs" "log" + "net" "os" panelletsencrypt "github.com/gameap/gameapctl/internal/actions/panel/letsencrypt" @@ -41,6 +42,10 @@ func Disable(cliCtx *cli.Context) error { return nil } + // Nothing this command writes touches HTTP_HOST or HTTP_BIND_IP, so the + // values read above still describe where the restarted panel will listen. + bind := panel.ResolveBindAddress(ctx, values) + if err = configenv.Update(configPath, lines, disableUpdates(values)); err != nil { return errors.WithMessage(err, "failed to write config") } @@ -51,7 +56,7 @@ func Disable(cliCtx *cli.Context) error { return errors.WithMessage(err, "failed to restart gameap") } - if err = waitForHTTP(ctx, values); err != nil { + if err = waitForHTTP(ctx, bind, values); err != nil { return err } @@ -80,14 +85,23 @@ func disableUpdates(values map[string]string) map[string]string { return updates } -func waitForHTTP(ctx context.Context, values map[string]string) error { +func waitForHTTP(ctx context.Context, bind panel.BindAddress, values map[string]string) error { httpPort := panel.ConfigValue(values, panel.HTTPPortKey) if httpPort == "" { httpPort = gameap.DefaultPanelPort } + hosts := bind.ProbeHosts() + err := waitFor(ctx, healthInterval, func() error { - return panelpkg.CheckInstallationV4(ctx, "127.0.0.1", httpPort, false) + return panel.ProbeEach(hosts, func(host string) error { + // The health check reports a bad response without saying which + // address gave it, and here there may be more than one. + return errors.WithMessage( + panelpkg.CheckInstallationV4(ctx, host, httpPort, false), + net.JoinHostPort(host, httpPort), + ) + }) }) if err != nil { return errors.WithMessagef(err, "the panel is not answering on HTTP port %s", httpPort) diff --git a/internal/actions/panel/https/enable.go b/internal/actions/panel/https/enable.go index 29def14..8444a89 100644 --- a/internal/actions/panel/https/enable.go +++ b/internal/actions/panel/https/enable.go @@ -60,6 +60,12 @@ func Enable(cliCtx *cli.Context) error { ) } + // Resolving from the values read above stays correct because nothing this + // command writes touches HTTP_HOST or HTTP_BIND_IP. + bind := panel.ResolveBindAddress(ctx, values) + + log.Println("The panel listens on:", bind) + setup, err := prepareCertificate(ctx, cliCtx, paths, values) if err != nil { return err @@ -70,7 +76,7 @@ func Enable(cliCtx *cli.Context) error { return err } - if err = checkHTTPSPort(values, httpsPort, paths.Scope); err != nil { + if err = checkHTTPSPort(values, bind, httpsPort, paths.Scope); err != nil { return err } @@ -82,7 +88,7 @@ func Enable(cliCtx *cli.Context) error { log.Println("config.env updated. Restarting gameap ...") - if err = restartAndVerify(ctx, paths, httpsPort, setup.leaf); err != nil { + if err = restartAndVerify(ctx, paths, bind, httpsPort, setup.leaf); err != nil { return rollback(ctx, paths, lines, err) } @@ -221,20 +227,26 @@ func forceHTTPSUpdate(cliCtx *cli.Context) *bool { // checkHTTPSPort probes the port only when the panel is not serving it already: // a rerun that keeps the port would otherwise fail against the panel's own // listener. -func checkHTTPSPort(values map[string]string, httpsPort, scope string) error { +func checkHTTPSPort(values map[string]string, bind panel.BindAddress, httpsPort, scope string) error { if panel.TLSEnabled(values) && panel.HTTPSPort(values) == httpsPort { return nil } - if err := utils.CheckPortAvailability("", httpsPort); err != nil { - return errors.WithMessage(err, portUnavailableMessage(httpsPort, scope)) + // Testing the address the panel binds rather than the wildcard also catches a + // bind address this machine does not have, which the panel would exit over. + if err := utils.CheckPortAvailability(bind.ListenAddr(), httpsPort); err != nil { + return errors.WithMessage(err, portUnavailableMessage(bind, httpsPort, scope)) } return nil } -func portUnavailableMessage(httpsPort, scope string) string { - message := fmt.Sprintf("port %s is not available", httpsPort) +func portUnavailableMessage(bind panel.BindAddress, httpsPort, scope string) string { + message := fmt.Sprintf("port %s is not available on %s", httpsPort, bind) + + if !bind.Wildcard() { + message += " (" + bind.Key + ")" + } port, err := strconv.Atoi(httpsPort) if err == nil && port < privilegedPortLimit && scope == gameap.ScopeUser { @@ -269,33 +281,43 @@ func warnUnreachablePath(paths gameap.PanelPaths, path string) { } func restartAndVerify( - ctx context.Context, paths gameap.PanelPaths, httpsPort string, expected *x509.Certificate, + ctx context.Context, + paths gameap.PanelPaths, + bind panel.BindAddress, + httpsPort string, + expected *x509.Certificate, ) error { if err := panel.Restart(ctx, panel.Options{Scope: paths.Scope}); err != nil { return errors.WithMessage(err, "failed to restart gameap") } - return waitForHTTPS(ctx, httpsPort, expected) + return waitForHTTPS(ctx, bind, httpsPort, expected) } // waitForHTTPS blocks until the panel answers a handshake with the certificate // that was just configured. This is not a nicety: the panel exits when it cannot // load the configured certificate, taking the plain HTTP listener down with it, // so an unverified change can leave the installation unreachable. -func waitForHTTPS(ctx context.Context, httpsPort string, expected *x509.Certificate) error { - addr := net.JoinHostPort("127.0.0.1", httpsPort) +func waitForHTTPS( + ctx context.Context, bind panel.BindAddress, httpsPort string, expected *x509.Certificate, +) error { + // The addresses are worked out once: a domain in HTTP_HOST costs a lookup, + // and inside the retry loop that would eat the whole verification budget. + addrs := bind.ProbeAddrs(httpsPort) err := waitFor(ctx, verifyInterval, func() error { - return probeHTTPS(ctx, addr, expected) + return panel.ProbeEach(addrs, func(addr string) error { + return probeCertificate(ctx, addr, expected) + }) }) if err != nil { - return errors.WithMessagef(err, "the panel is not serving HTTPS on port %s", httpsPort) + return errors.WithMessagef(err, "the panel is not serving HTTPS on %s", strings.Join(addrs, ", ")) } return nil } -func probeHTTPS(ctx context.Context, addr string, expected *x509.Certificate) error { +func probeCertificate(ctx context.Context, addr string, expected *x509.Certificate) error { result, err := tlsprobe.Leaf(ctx, addr, probeTimeout) if err != nil { return err @@ -306,7 +328,7 @@ func probeHTTPS(ctx context.Context, addr string, expected *x509.Certificate) er } if !bytes.Equal(result.Leaf.Raw, expected.Raw) { - return errors.New("the panel is still serving another certificate") + return errors.Errorf("%s is serving another certificate", addr) } return nil diff --git a/internal/actions/panel/https/https_internal_test.go b/internal/actions/panel/https/https_internal_test.go index 751cd86..825a52f 100644 --- a/internal/actions/panel/https/https_internal_test.go +++ b/internal/actions/panel/https/https_internal_test.go @@ -1,6 +1,7 @@ package https import ( + "crypto/tls" "net" "os" "path/filepath" @@ -10,6 +11,7 @@ import ( "github.com/gameap/gameapctl/pkg/certgen" "github.com/gameap/gameapctl/pkg/gameap" + "github.com/gameap/gameapctl/pkg/panel" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -386,3 +388,139 @@ func ipStrings(ips []net.IP) []string { return out } + +func TestProbeCertificate(t *testing.T) { + opts := certgen.SelfSignedOptions{ + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + ValidFor: day, + } + + certPath, keyPath := writePair(t, opts) + + expected, err := loadLeaf(certPath, keyPath) + require.NoError(t, err) + + pair, err := tls.LoadX509KeyPair(certPath, keyPath) + require.NoError(t, err) + + addr := serveTLS(t, pair) + + t.Run("the expected certificate passes", func(t *testing.T) { + require.NoError(t, probeCertificate(t.Context(), addr, expected)) + }) + + t.Run("another certificate names the address", func(t *testing.T) { + otherPath, otherKeyPath := writePair(t, opts) + + other, loadErr := loadLeaf(otherPath, otherKeyPath) + require.NoError(t, loadErr) + + probeErr := probeCertificate(t.Context(), addr, other) + + require.Error(t, probeErr) + assert.Contains(t, probeErr.Error(), addr) + assert.Contains(t, probeErr.Error(), "another certificate") + }) + + t.Run("a refused address is reported", func(t *testing.T) { + probeErr := probeCertificate(t.Context(), closedAddr(t), expected) + + require.Error(t, probeErr) + assert.Contains(t, probeErr.Error(), "cannot reach TLS server") + }) +} + +// closedAddr is an address nothing listens on, taken by opening a listener and +// closing it again so that the port is known to be free. +func closedAddr(t *testing.T) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + addr := listener.Addr().String() + require.NoError(t, listener.Close()) + + return addr +} + +func TestPortUnavailableMessage(t *testing.T) { + tests := []struct { + name string + bind panel.BindAddress + scope string + wantParts []string + wantMissing []string + }{ + { + name: "a bound address names the key it came from", + bind: panel.BindAddress{IP: "2.29.29.94", Key: panel.HTTPHostKey}, + scope: gameap.ScopeSystem, + wantParts: []string{"443", "2.29.29.94", panel.HTTPHostKey}, + }, + { + name: "a wildcard has no key to name", + bind: panel.BindAddress{IP: "0.0.0.0", Key: panel.HTTPHostKey}, + scope: gameap.ScopeSystem, + wantParts: []string{"443", "every interface"}, + wantMissing: []string{panel.HTTPHostKey}, + }, + { + name: "the user scope keeps its privileged port hint", + bind: panel.BindAddress{Key: panel.HTTPHostKey}, + scope: gameap.ScopeUser, + wantParts: []string{"CAP_NET_BIND_SERVICE"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + message := portUnavailableMessage(test.bind, "443", test.scope) + + for _, part := range test.wantParts { + assert.Contains(t, message, part) + } + + for _, part := range test.wantMissing { + assert.NotContains(t, message, part) + } + }) + } +} + +// serveTLS answers handshakes with pair on loopback until the test ends. +func serveTLS(t *testing.T, pair tls.Certificate) string { + t.Helper() + + listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{pair}, + MinVersion: tls.VersionTLS12, + }) + require.NoError(t, err) + + t.Cleanup(func() { + _ = listener.Close() + }) + + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + + go func() { + defer func() { + _ = conn.Close() + }() + + if tlsConn, ok := conn.(*tls.Conn); ok { + _ = tlsConn.Handshake() + } + }() + } + }() + + return listener.Addr().String() +} diff --git a/internal/actions/panel/https/status.go b/internal/actions/panel/https/status.go index f2591de..1e0159b 100644 --- a/internal/actions/panel/https/status.go +++ b/internal/actions/panel/https/status.go @@ -5,6 +5,7 @@ import ( "crypto/x509" "log" "net" + "strings" "time" panelpkg "github.com/gameap/gameapctl/internal/pkg/panel" @@ -28,10 +29,12 @@ func Status(cliCtx *cli.Context) error { } source := panel.EffectiveCertSource(values) + bind := panel.ResolveBindAddress(ctx, values) log.Println("Installation scope:", paths.Scope) log.Println("Config: ", paths.ConfigFilePath) log.Println("HTTP: ", httpSummary(values)) + log.Println("Listening on: ", bind) log.Println("Certificate source:", source) if source == panel.CertSourceNone { @@ -46,7 +49,7 @@ func Status(cliCtx *cli.Context) error { log.Println("Redirect HTTP: ", panel.ForceHTTPS(values)) reportSource(source, values) - reportServedCertificate(ctx, httpsPort) + reportServedCertificate(ctx, bind, httpsPort) return nil } @@ -108,12 +111,26 @@ func reportCertificate(leaf *x509.Certificate) { // reportServedCertificate reads the certificate the panel is actually serving. // A fingerprint that differs from the configured one means the running process // predates the last change and has to be restarted. -func reportServedCertificate(ctx context.Context, httpsPort string) { - addr := net.JoinHostPort("127.0.0.1", httpsPort) +func reportServedCertificate(ctx context.Context, bind panel.BindAddress, httpsPort string) { + addrs := bind.ProbeAddrs(httpsPort) - result, err := tlsprobe.Leaf(ctx, addr, probeTimeout) + var ( + served string + result tlsprobe.Result + ) + + err := panel.ProbeEach(addrs, func(addr string) error { + probed, probeErr := tlsprobe.Leaf(ctx, addr, probeTimeout) + if probeErr != nil { + return probeErr + } + + served, result = addr, probed + + return nil + }) if err != nil { - log.Printf("Nothing is listening on %s: %v\n", addr, err) + log.Printf("Nothing is listening on %s: %v\n", strings.Join(addrs, ", "), err) return } @@ -122,16 +139,16 @@ func reportServedCertificate(ctx context.Context, httpsPort string) { case result.HandshakeErr != nil: // The certificate arrives before a server that requires a client one // aborts, so a failed handshake is still worth reporting alongside it. - log.Printf("The TLS handshake with %s failed: %v\n", addr, result.HandshakeErr) + log.Printf("The TLS handshake with %s failed: %v\n", served, result.HandshakeErr) case result.Leaf == nil: - log.Printf("%s answered without a certificate.\n", addr) + log.Printf("%s answered without a certificate.\n", served) } if result.Leaf == nil { return } - log.Println("Served on ", addr) + log.Println("Served on ", served) log.Println(" Names: ", certificateNames(result.Leaf)) log.Println(" Expires: ", expirySummary(result.Leaf.NotAfter, time.Now())) log.Println(" Fingerprint:", fingerprint(result.Leaf)) diff --git a/internal/actions/panel/update/panel_update_v4.go b/internal/actions/panel/update/panel_update_v4.go index 6061238..38ff24f 100644 --- a/internal/actions/panel/update/panel_update_v4.go +++ b/internal/actions/panel/update/panel_update_v4.go @@ -5,6 +5,7 @@ import ( "fmt" "io/fs" "log" + "net" "os" "path/filepath" "runtime" @@ -220,20 +221,19 @@ func restoreBackupV4(backupPath, currentBinary string) error { return nil } -// readConfigEnv reads the address the panel answers on. HTTPS is derived from -// the certificate source rather than from a flag: the panel has no key that -// switches TLS on, it serves it as soon as a certificate is configured, and with -// TLS_FORCE_HTTPS the plain HTTP endpoint only answers with a redirect. -func readConfigEnv(configPath string) (host, port string, httpsEnabled bool, err error) { +// readConfigEnv reads the addresses the panel answers on. They come from where +// it binds rather than from HTTP_HOST alone, which on a domain behind NAT names +// somewhere this machine cannot reach. HTTPS is derived from the certificate +// source rather than from a flag: the panel has no key that switches TLS on, it +// serves it as soon as a certificate is configured, and with TLS_FORCE_HTTPS the +// plain HTTP endpoint only answers with a redirect. +func readConfigEnv(ctx context.Context, configPath string) (hosts []string, port string, httpsEnabled bool, err error) { _, values, err := configenv.Read(configPath) if err != nil { - return "", "", false, err + return nil, "", false, err } - host = panel.ConfigValue(values, panel.HTTPHostKey) - if host == "" { - host = defaultHealthCheckHost - } + hosts = panel.ResolveBindAddress(ctx, values).ProbeHosts() port = panel.ConfigValue(values, panel.HTTPPortKey) if port == "" { @@ -245,18 +245,27 @@ func readConfigEnv(configPath string) (host, port string, httpsEnabled bool, err port = panel.HTTPSPort(values) } - return host, port, httpsEnabled, nil + return hosts, port, httpsEnabled, nil } -// checkHealth performs health checks on the GameAP instance. -func checkHealth(ctx context.Context, host, port string, httpsEnabled bool) error { +// checkHealth performs health checks on the GameAP instance, at every address it +// may answer on: the health check names none of them on its own, so each failure +// is labelled with the address that produced it. +func checkHealth(ctx context.Context, hosts []string, port string, httpsEnabled bool) error { + check := func(host string) error { + return errors.WithMessage( + installpkg.CheckInstallationV4(ctx, host, port, httpsEnabled), + net.JoinHostPort(host, port), + ) + } + for i := 0; i < healthCheckRetries; i++ { if i > 0 { log.Printf("Retry %d/%d...\n", i+1, healthCheckRetries) time.Sleep(healthCheckDelay) } - if err := installpkg.CheckInstallationV4(ctx, host, port, httpsEnabled); err == nil { + if err := panel.ProbeEach(hosts, check); err == nil { log.Println("Health check passed!") return nil @@ -423,16 +432,16 @@ func startAndVerifyV4( log.Println("Checking if new version is working...") - httpHost, httpPort, httpsEnabled, err := readConfigEnv(paths.ConfigFilePath) + httpHosts, httpPort, httpsEnabled, err := readConfigEnv(ctx, paths.ConfigFilePath) if err != nil { log.Printf("Warning: failed to read config.env: %v\n", err) - httpHost = "127.0.0.1" - httpPort = "8025" + httpHosts = []string{defaultHealthCheckHost} + httpPort = defaultHealthCheckPort httpsEnabled = false } - if err := checkHealth(ctx, httpHost, httpPort, httpsEnabled); err != nil { + if err := checkHealth(ctx, httpHosts, httpPort, httpsEnabled); err != nil { log.Printf("Health check failed: %v\n", err) log.Println("Rolling back to previous version...") diff --git a/internal/pkg/panel/install.go b/internal/pkg/panel/install.go index ba100db..831b81f 100644 --- a/internal/pkg/panel/install.go +++ b/internal/pkg/panel/install.go @@ -210,17 +210,17 @@ func createHealthURL(host, port string, https bool, endpoint string) string { return scheme + "://" + hostPort + endpoint } -// localHTTPSClient deliberately does not verify the panel's certificate. An +// localProbeClient deliberately does not verify the panel's certificate. An // installation serving HTTPS from a self-signed certificate is the common case // here, and a probe of the machine gameapctl is running on is a liveness check // against the panel that was just installed or restarted, not a trust decision. // Nothing but the certificate is unverified: the transport keeps the timeouts // the default one carries. The proxy is dropped: a probe that stays on this -// machine has no business leaving it, and the unspecified address is not one -// of the hosts the environment proxy bypasses on its own. -var localHTTPSClient = newLocalHTTPSClient() +// machine has no business leaving it, and neither the unspecified address nor +// the machine's own public one is bypassed by the environment proxy on its own. +var localProbeClient = newLocalProbeClient() -func newLocalHTTPSClient() *http.Client { +func newLocalProbeClient() *http.Client { transport, ok := http.DefaultTransport.(*http.Transport) if !ok { return http.DefaultClient @@ -235,27 +235,42 @@ func newLocalHTTPSClient() *http.Client { } // healthCheckClient picks the client for a probe. A probe that leaves this -// machine is verified like any other request; only the local self-signed case -// is exempt. +// machine is verified and proxied like any other request; a probe that stays on +// it is neither. func healthCheckClient(u *url.URL) *http.Client { - if u.Scheme != "https" || !isLocalHost(u.Hostname()) { + if !isLocalHost(u.Hostname()) { return http.DefaultClient } - return localHTTPSClient + return localProbeClient } // isLocalHost reports whether a request to host stays on this machine. The // unspecified address counts: the panel is configured with it to listen -// everywhere, and a connection to it lands on loopback. +// everywhere, and a connection to it lands on loopback. So does an address of +// one of the interfaces, which is what HTTP_HOST holds on an installation that +// answers on its public address and nowhere else. func isLocalHost(host string) bool { if host == "localhost" { return true } ip := net.ParseIP(host) + if ip == nil { + return false + } + + if ip.IsLoopback() || ip.IsUnspecified() { + return true + } + + for _, local := range utils.DetectIPs() { + if ip.Equal(net.ParseIP(local)) { + return true + } + } - return ip != nil && (ip.IsLoopback() || ip.IsUnspecified()) + return false } func checkInstallation(ctx context.Context, healthURL string) error { diff --git a/internal/pkg/panel/install_internal_test.go b/internal/pkg/panel/install_internal_test.go index 03a4c58..1005f17 100644 --- a/internal/pkg/panel/install_internal_test.go +++ b/internal/pkg/panel/install_internal_test.go @@ -1,8 +1,10 @@ package panel import ( + "net" "testing" + "github.com/gameap/gameapctl/pkg/utils" "github.com/stretchr/testify/assert" ) @@ -29,3 +31,46 @@ func Test_createHealthURL(t *testing.T) { }) } } + +func Test_isLocalHost(t *testing.T) { + tests := []struct { + name string + host string + expected bool + }{ + {name: "localhost", host: "localhost", expected: true}, + {name: "loopback_ipv4", host: "127.0.0.1", expected: true}, + {name: "loopback_ipv6", host: "::1", expected: true}, + {name: "unspecified_ipv4", host: "0.0.0.0", expected: true}, + {name: "unspecified_ipv6", host: "::", expected: true}, + {name: "documentation_address", host: "203.0.113.10", expected: false}, + {name: "domain", host: "panel.example.com", expected: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, isLocalHost(test.host)) + }) + } +} + +// Test_isLocalHost_interfaceAddress covers the case the panel is configured +// with on a server that answers on its public address only: HTTP_HOST holds an +// address of a local interface, and a probe of it stays on this machine. +func Test_isLocalHost_interfaceAddress(t *testing.T) { + var address string + + for _, ip := range utils.DetectIPs() { + parsed := net.ParseIP(ip) + if parsed != nil && !parsed.IsLoopback() && !parsed.IsUnspecified() { + address = ip + + break + } + } + + if address == "" { + t.Skip("no non-loopback interface address on this machine") + } + + assert.True(t, isLocalHost(address)) +} diff --git a/pkg/panel/bindaddr.go b/pkg/panel/bindaddr.go new file mode 100644 index 0000000..5214652 --- /dev/null +++ b/pkg/panel/bindaddr.go @@ -0,0 +1,207 @@ +package panel + +import ( + "context" + "net" + "slices" + "strings" + "time" + + "github.com/gameap/gameapctl/pkg/utils" + "github.com/pkg/errors" +) + +const ( + wildcardIPv4 = "0.0.0.0" + loopbackIPv4 = "127.0.0.1" + loopbackIPv6 = "::1" + + // bindResolveTimeout matches the timeout the panel gives its own lookup. A + // shorter one would report a bind on every interface where the panel got an + // answer in time and bound a single address, which is the mistake this file + // exists to avoid. + bindResolveTimeout = 10 * time.Second +) + +// BindAddress is where the panel's HTTP and HTTPS listeners land, derived from +// config.env the way the panel derives it at start. +type BindAddress struct { + // IP is what the panel passes to net.JoinHostPort. Empty or unspecified + // means every interface. + IP string + + // Key names the config.env key IP was derived from, so that a message about + // an address that does not work can point at what has to change. + Key string +} + +// ResolveBindAddress repeats the panel's own derivation: HTTP_BIND_IP is taken +// as it is, and HTTP_HOST is resolved only when it is not already an address. +func ResolveBindAddress(ctx context.Context, values map[string]string) BindAddress { + return defaultBindResolver().resolve(ctx, values) +} + +// Wildcard reports whether the panel listens on every interface, which is what +// an empty or unspecified address means to net.Listen. +func (a BindAddress) Wildcard() bool { + if a.IP == "" { + return true + } + + ip := net.ParseIP(a.IP) + + return ip != nil && ip.IsUnspecified() +} + +// ListenAddr is the address to bind when testing whether a port is free, which +// is the empty string for every interface. +func (a BindAddress) ListenAddr() string { + if a.Wildcard() { + return "" + } + + return a.IP +} + +// ProbeHosts lists the addresses the listeners answer on from the panel's own +// machine, the one it binds first. Loopback follows it because this derivation +// can disagree with the panel that is actually running: the name in HTTP_HOST +// may resolve differently now than it did when the panel started. +func (a BindAddress) ProbeHosts() []string { + hosts := make([]string, 0, 2) + + if !a.Wildcard() { + hosts = append(hosts, a.IP) + } + + for _, fallback := range loopbackFor(a.IP) { + if !slices.Contains(hosts, fallback) { + hosts = append(hosts, fallback) + } + } + + return hosts +} + +// ProbeAddrs is ProbeHosts with a port on every entry. +func (a BindAddress) ProbeAddrs(port string) []string { + hosts := a.ProbeHosts() + + addrs := make([]string, 0, len(hosts)) + for _, host := range hosts { + addrs = append(addrs, net.JoinHostPort(host, port)) + } + + return addrs +} + +func (a BindAddress) String() string { + if a.Wildcard() { + return "every interface" + } + + return a.IP +} + +// loopbackFor keeps the fallback in the address family of the bind address, so +// that an installation reachable over IPv6 only is still probed. +func loopbackFor(bind string) []string { + ip := net.ParseIP(bind) + if ip == nil || ip.To4() != nil { + return []string{loopbackIPv4} + } + + if ip.IsUnspecified() { + return []string{loopbackIPv6, loopbackIPv4} + } + + return []string{loopbackIPv6} +} + +// ProbeEach tries every address in order and gives up only once none of them +// answers, reporting what each one said. The panel binds a single address and +// which one that is depends on config.env, so a refusal from one of them says +// nothing on its own. +func ProbeEach(addrs []string, probe func(addr string) error) error { + if len(addrs) == 0 { + return errors.New("no address to probe the panel at") + } + + var first error + + messages := make([]string, 0, len(addrs)) + + for _, addr := range addrs { + err := probe(addr) + if err == nil { + return nil + } + + if first == nil { + first = err + } + + messages = append(messages, err.Error()) + } + + if len(messages) == 1 { + return first + } + + return errors.New(strings.Join(messages, "; ")) +} + +// bindResolver holds the two lookups the derivation needs, so that the tests +// depend on neither DNS nor the interfaces of the machine they run on. +type bindResolver struct { + lookupHost func(ctx context.Context, host string) ([]string, error) + localIPs func() []string +} + +func defaultBindResolver() bindResolver { + return bindResolver{ + lookupHost: net.DefaultResolver.LookupHost, + localIPs: utils.DetectIPs, + } +} + +func (r bindResolver) resolve(ctx context.Context, values map[string]string) BindAddress { + if bindIP := ConfigValue(values, HTTPBindIPKey); bindIP != "" { + // The panel passes this key straight to net.JoinHostPort without + // parsing it, so a zoned literal such as fe80::1%eth0 survives. + return BindAddress{IP: bindIP, Key: HTTPBindIPKey} + } + + host := ConfigValue(values, HTTPHostKey) + + // An empty host and the two wildcards the panel special-cases all end up + // here: "0.0.0.0" and "::" parse as addresses like any other. + if host == "" || net.ParseIP(host) != nil { + return BindAddress{IP: host, Key: HTTPHostKey} + } + + return BindAddress{IP: r.resolveDomain(ctx, host), Key: HTTPHostKey} +} + +// resolveDomain returns the resolved address the panel would bind, which is the +// first one that belongs to this machine. Everything else leaves the panel on +// every interface: it cannot bind an address it does not have. +func (r bindResolver) resolveDomain(ctx context.Context, host string) string { + ctx, cancel := context.WithTimeout(ctx, bindResolveTimeout) + defer cancel() + + resolved, err := r.lookupHost(ctx, host) + if err != nil { + return wildcardIPv4 + } + + local := r.localIPs() + + for _, addr := range resolved { + if slices.Contains(local, addr) { + return addr + } + } + + return wildcardIPv4 +} diff --git a/pkg/panel/bindaddr_test.go b/pkg/panel/bindaddr_test.go new file mode 100644 index 0000000..031d0c2 --- /dev/null +++ b/pkg/panel/bindaddr_test.go @@ -0,0 +1,332 @@ +package panel + +import ( + "context" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveBindAddress(t *testing.T) { + const domain = "panel.example.com" + + tests := []struct { + name string + values map[string]string + resolved []string + resolveErr error + localIPs []string + wantIP string + wantKey string + wantListenAddr string + wantProbeHosts []string + wantLookups []string + }{ + { + name: "nothing configured", + values: map[string]string{}, + wantIP: "", + wantKey: HTTPHostKey, + wantListenAddr: "", + wantProbeHosts: []string{"127.0.0.1"}, + }, + { + name: "ipv4 wildcard", + values: map[string]string{HTTPHostKey: "0.0.0.0"}, + wantIP: "0.0.0.0", + wantKey: HTTPHostKey, + wantListenAddr: "", + wantProbeHosts: []string{"127.0.0.1"}, + }, + { + name: "ipv6 wildcard", + values: map[string]string{HTTPHostKey: "::"}, + wantIP: "::", + wantKey: HTTPHostKey, + wantListenAddr: "", + wantProbeHosts: []string{"::1", "127.0.0.1"}, + }, + { + name: "public ipv4 literal", + values: map[string]string{HTTPHostKey: "2.29.29.94"}, + wantIP: "2.29.29.94", + wantKey: HTTPHostKey, + wantListenAddr: "2.29.29.94", + wantProbeHosts: []string{"2.29.29.94", "127.0.0.1"}, + }, + { + name: "ipv6 literal", + values: map[string]string{HTTPHostKey: "2001:db8::1"}, + wantIP: "2001:db8::1", + wantKey: HTTPHostKey, + wantListenAddr: "2001:db8::1", + wantProbeHosts: []string{"2001:db8::1", "::1"}, + }, + { + name: "quoted and padded value", + values: map[string]string{HTTPHostKey: ` "2.29.29.94" `}, + wantIP: "2.29.29.94", + wantKey: HTTPHostKey, + wantListenAddr: "2.29.29.94", + wantProbeHosts: []string{"2.29.29.94", "127.0.0.1"}, + }, + { + name: "bind ip wins over a domain host", + values: map[string]string{HTTPBindIPKey: "10.0.0.5", HTTPHostKey: domain}, + wantIP: "10.0.0.5", + wantKey: HTTPBindIPKey, + wantListenAddr: "10.0.0.5", + wantProbeHosts: []string{"10.0.0.5", "127.0.0.1"}, + }, + { + name: "wildcard bind ip wins over a domain host", + values: map[string]string{HTTPBindIPKey: "0.0.0.0", HTTPHostKey: domain}, + wantIP: "0.0.0.0", + wantKey: HTTPBindIPKey, + wantListenAddr: "", + wantProbeHosts: []string{"127.0.0.1"}, + }, + { + name: "zoned bind ip is passed through unparsed", + values: map[string]string{HTTPBindIPKey: "fe80::1%eth0"}, + wantIP: "fe80::1%eth0", + wantKey: HTTPBindIPKey, + wantListenAddr: "fe80::1%eth0", + wantProbeHosts: []string{"fe80::1%eth0", "127.0.0.1"}, + }, + { + name: "domain on a local interface", + values: map[string]string{HTTPHostKey: domain}, + resolved: []string{"203.0.113.10"}, + localIPs: []string{"127.0.0.1", "203.0.113.10"}, + wantIP: "203.0.113.10", + wantKey: HTTPHostKey, + wantListenAddr: "203.0.113.10", + wantProbeHosts: []string{"203.0.113.10", "127.0.0.1"}, + wantLookups: []string{domain}, + }, + { + name: "first local address of several wins", + values: map[string]string{HTTPHostKey: domain}, + resolved: []string{"198.51.100.7", "203.0.113.10"}, + localIPs: []string{"203.0.113.10"}, + wantIP: "203.0.113.10", + wantKey: HTTPHostKey, + wantListenAddr: "203.0.113.10", + wantProbeHosts: []string{"203.0.113.10", "127.0.0.1"}, + wantLookups: []string{domain}, + }, + { + name: "ipv6 domain on a local interface", + values: map[string]string{HTTPHostKey: domain}, + resolved: []string{"2001:db8::1"}, + localIPs: []string{"2001:db8::1"}, + wantIP: "2001:db8::1", + wantKey: HTTPHostKey, + wantListenAddr: "2001:db8::1", + wantProbeHosts: []string{"2001:db8::1", "::1"}, + wantLookups: []string{domain}, + }, + { + name: "localhost resolves to loopback without repeating it", + values: map[string]string{HTTPHostKey: "localhost"}, + resolved: []string{"127.0.0.1", "::1"}, + localIPs: []string{"127.0.0.1", "::1"}, + wantIP: "127.0.0.1", + wantKey: HTTPHostKey, + wantListenAddr: "127.0.0.1", + wantProbeHosts: []string{"127.0.0.1"}, + wantLookups: []string{"localhost"}, + }, + { + name: "unresolvable domain", + values: map[string]string{HTTPHostKey: domain}, + resolveErr: assert.AnError, + wantIP: "0.0.0.0", + wantKey: HTTPHostKey, + wantListenAddr: "", + wantProbeHosts: []string{"127.0.0.1"}, + wantLookups: []string{domain}, + }, + { + name: "domain resolving off box", + values: map[string]string{HTTPHostKey: domain}, + resolved: []string{"198.51.100.7"}, + localIPs: []string{"127.0.0.1", "10.0.0.5"}, + wantIP: "0.0.0.0", + wantKey: HTTPHostKey, + wantListenAddr: "", + wantProbeHosts: []string{"127.0.0.1"}, + wantLookups: []string{domain}, + }, + { + name: "no interface addresses", + values: map[string]string{HTTPHostKey: domain}, + resolved: []string{"203.0.113.10"}, + localIPs: nil, + wantIP: "0.0.0.0", + wantKey: HTTPHostKey, + wantListenAddr: "", + wantProbeHosts: []string{"127.0.0.1"}, + wantLookups: []string{domain}, + }, + { + name: "host carrying a port is treated as a name", + values: map[string]string{HTTPHostKey: "example.com:8080"}, + resolveErr: assert.AnError, + wantIP: "0.0.0.0", + wantKey: HTTPHostKey, + wantListenAddr: "", + wantProbeHosts: []string{"127.0.0.1"}, + wantLookups: []string{"example.com:8080"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var lookups []string + + resolver := bindResolver{ + lookupHost: func(ctx context.Context, host string) ([]string, error) { + _, ok := ctx.Deadline() + assert.True(t, ok, "the lookup has to be bounded by a deadline") + + lookups = append(lookups, host) + + return test.resolved, test.resolveErr + }, + localIPs: func() []string { + return test.localIPs + }, + } + + bind := resolver.resolve(t.Context(), test.values) + + assert.Equal(t, test.wantIP, bind.IP) + assert.Equal(t, test.wantKey, bind.Key) + assert.Equal(t, test.wantListenAddr, bind.ListenAddr()) + assert.Equal(t, test.wantProbeHosts, bind.ProbeHosts()) + assert.Equal(t, test.wantLookups, lookups) + }) + } +} + +func TestProbeEach(t *testing.T) { + const ( + bound = "2.29.29.94:443" + loopback = "127.0.0.1:443" + ) + + tests := []struct { + name string + addrs []string + answering string + wantCalls []string + wantErr []string + }{ + { + name: "the bound address answers", + addrs: []string{bound, loopback}, + answering: bound, + wantCalls: []string{bound}, + }, + { + name: "the bound address is refused and loopback answers", + addrs: []string{bound, loopback}, + answering: loopback, + wantCalls: []string{bound, loopback}, + }, + { + name: "neither answers", + addrs: []string{bound, loopback}, + wantCalls: []string{bound, loopback}, + wantErr: []string{bound, loopback}, + }, + { + name: "a single address keeps its own error", + addrs: []string{loopback}, + wantCalls: []string{loopback}, + wantErr: []string{loopback}, + }, + { + name: "no address at all is a failure", + wantErr: []string{"no address"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var calls []string + + err := ProbeEach(test.addrs, func(addr string) error { + calls = append(calls, addr) + + if addr == test.answering { + return nil + } + + return errors.Errorf("%s refused the connection", addr) + }) + + assert.Equal(t, test.wantCalls, calls) + + if len(test.wantErr) == 0 { + require.NoError(t, err) + + return + } + + require.Error(t, err) + + for _, want := range test.wantErr { + assert.Contains(t, err.Error(), want) + } + }) + } +} + +func TestBindAddressProbeAddrs(t *testing.T) { + tests := []struct { + name string + bind BindAddress + port string + want []string + }{ + { + name: "wildcard", + bind: BindAddress{IP: "0.0.0.0"}, + port: "443", + want: []string{"127.0.0.1:443"}, + }, + { + name: "ipv4 literal", + bind: BindAddress{IP: "2.29.29.94"}, + port: "443", + want: []string{"2.29.29.94:443", "127.0.0.1:443"}, + }, + { + name: "ipv6 literal is bracketed", + bind: BindAddress{IP: "2001:db8::1"}, + port: "8443", + want: []string{"[2001:db8::1]:8443", "[::1]:8443"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + addrs := test.bind.ProbeAddrs(test.port) + + require.Len(t, addrs, len(test.want)) + assert.Equal(t, test.want, addrs) + }) + } +} + +func TestBindAddressString(t *testing.T) { + assert.Equal(t, "every interface", BindAddress{}.String()) + assert.Equal(t, "every interface", BindAddress{IP: "0.0.0.0"}.String()) + assert.Equal(t, "every interface", BindAddress{IP: "::"}.String()) + assert.Equal(t, "2.29.29.94", BindAddress{IP: "2.29.29.94"}.String()) +} diff --git a/pkg/panel/tls.go b/pkg/panel/tls.go index 322f259..52d7295 100644 --- a/pkg/panel/tls.go +++ b/pkg/panel/tls.go @@ -7,11 +7,13 @@ import ( "github.com/gameap/gameapctl/pkg/configenv" ) -// Keys of config.env that decide how the panel serves HTTP and HTTPS. The panel -// reads them at start and picks a certificate source from whatever is set; -// gameapctl only ever writes them. +// Keys of config.env that decide where the panel listens and how it serves HTTP +// and HTTPS. The panel reads them at start and picks a certificate source from +// whatever is set; gameapctl writes all of them but HTTP_BIND_IP, which it only +// reads to know where the listeners land. const ( HTTPHostKey = "HTTP_HOST" + HTTPBindIPKey = "HTTP_BIND_IP" HTTPPortKey = "HTTP_PORT" HTTPSPortKey = "HTTPS_PORT" TLSCertFileKey = "TLS_CERT_FILE" From 09fb177773baf10e062982cb39795119e37b3e1e Mon Sep 17 00:00:00 2001 From: et-nik Date: Wed, 2 Sep 2026 16:48:44 +0200 Subject: [PATCH 2/2] review --- internal/actions/panel/https/enable.go | 40 +++++++++++++++---- .../panel/https/https_internal_test.go | 15 ++++++- internal/pkg/panel/install.go | 26 ++++++++---- internal/pkg/panel/install_internal_test.go | 23 ++++++++++- 4 files changed, 85 insertions(+), 19 deletions(-) diff --git a/internal/actions/panel/https/enable.go b/internal/actions/panel/https/enable.go index 8444a89..51e937e 100644 --- a/internal/actions/panel/https/enable.go +++ b/internal/actions/panel/https/enable.go @@ -92,7 +92,7 @@ func Enable(cliCtx *cli.Context) error { return rollback(ctx, paths, lines, err) } - reportEnabled(cliCtx, values, setup, httpsPort) + reportEnabled(cliCtx, values, bind, setup, httpsPort) return nil } @@ -354,9 +354,15 @@ func rollback(ctx context.Context, paths gameap.PanelPaths, lines []string, caus return errors.WithMessage(cause, "HTTPS is not enabled, the previous configuration is restored") } -func reportEnabled(cliCtx *cli.Context, values map[string]string, setup certificateSetup, httpsPort string) { +func reportEnabled( + cliCtx *cli.Context, + values map[string]string, + bind panel.BindAddress, + setup certificateSetup, + httpsPort string, +) { log.Println("HTTPS is enabled.") - log.Println(" URL: ", panelURL(values, httpsPort)) + log.Println(" URL: ", panelURL(values, bind, httpsPort)) log.Println(" Certificate:", setup.certPath) log.Println(" Private key:", setup.keyPath) log.Println(" Names: ", certificateNames(setup.leaf)) @@ -384,11 +390,11 @@ func reportEnabled(cliCtx *cli.Context, values map[string]string, setup certific log.Printf("HTTP is still served on port %s. Pass --force-https to redirect it.\n", httpPort) } -func panelURL(values map[string]string, httpsPort string) string { - host := panel.ConfigValue(values, panel.HTTPHostKey) - if host == "" || host == wildcardHost { - host = localhostName - } +// panelURL names the panel where a browser can open it: what HTTP_HOST says +// when it names an address, then the one the listeners are pinned to, which +// HTTP_BIND_IP can set on its own. Loopback is left when neither names one. +func panelURL(values map[string]string, bind panel.BindAddress, httpsPort string) string { + host := firstNamedHost(panel.ConfigValue(values, panel.HTTPHostKey), bind.IP) if httpsPort == panel.DefaultHTTPSPort { return "https://" + urlHost(host) @@ -397,6 +403,24 @@ func panelURL(values map[string]string, httpsPort string) string { return "https://" + net.JoinHostPort(host, httpsPort) } +// firstNamedHost returns the first candidate that names a host to connect to, +// which the wildcards and the empty string do not. +func firstNamedHost(candidates ...string) string { + for _, host := range candidates { + if host == "" { + continue + } + + if ip := net.ParseIP(host); ip != nil && ip.IsUnspecified() { + continue + } + + return host + } + + return localhostName +} + // urlHost brackets an IPv6 literal, which a URL needs even where there is no // port to separate it from. net.JoinHostPort does it for every other case. func urlHost(host string) string { diff --git a/internal/actions/panel/https/https_internal_test.go b/internal/actions/panel/https/https_internal_test.go index 825a52f..49b8ad8 100644 --- a/internal/actions/panel/https/https_internal_test.go +++ b/internal/actions/panel/https/https_internal_test.go @@ -307,6 +307,7 @@ func TestPanelURL(t *testing.T) { tests := []struct { name string host string + bind string port string want string }{ @@ -318,13 +319,23 @@ func TestPanelURL(t *testing.T) { want: "https://[2001:db8::1]"}, {name: "an address is bracketed with a port", host: "2001:db8::1", port: "8443", want: "https://[2001:db8::1]:8443"}, - {name: "the wildcard host becomes loopback", host: "0.0.0.0", port: "8443", + {name: "the wildcard host becomes loopback", host: "0.0.0.0", bind: "0.0.0.0", port: "8443", want: "https://localhost:8443"}, + {name: "an unset host becomes loopback", port: "8443", + want: "https://localhost:8443"}, + {name: "a pinned listener names the panel when the host does not", bind: "10.0.0.5", port: "8443", + want: "https://10.0.0.5:8443"}, + {name: "a configured host wins over the address it is pinned to", + host: "panel.example.com", bind: "10.0.0.5", port: "8443", + want: "https://panel.example.com:8443"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - assert.Equal(t, test.want, panelURL(map[string]string{"HTTP_HOST": test.host}, test.port)) + values := map[string]string{panel.HTTPHostKey: test.host} + bind := panel.BindAddress{IP: test.bind, Key: panel.HTTPBindIPKey} + + assert.Equal(t, test.want, panelURL(values, bind, test.port)) }) } } diff --git a/internal/pkg/panel/install.go b/internal/pkg/panel/install.go index 831b81f..399a7d2 100644 --- a/internal/pkg/panel/install.go +++ b/internal/pkg/panel/install.go @@ -193,21 +193,25 @@ func CheckInstallationV4(ctx context.Context, host, port string, https bool) err } func createHealthURL(host, port string, https bool, endpoint string) string { - if strings.Contains(host, ":") { - host = "[" + strings.Trim(host, "[]") + "]" - } - scheme, defaultPort := "http", "80" if https { scheme, defaultPort = "https", "443" } - hostPort := host - if port != defaultPort { - hostPort = host + ":" + port + host = strings.Trim(host, "[]") + + authority := net.JoinHostPort(host, port) + if port == defaultPort { + authority = host + if strings.Contains(host, ":") { + authority = "[" + host + "]" + } } - return scheme + "://" + hostPort + endpoint + // The URL is assembled rather than concatenated so that the interface zone + // of a link-local address is percent-encoded. Written out as it stands, the + // "%eth0" is an invalid escape and no parser accepts the result. + return (&url.URL{Scheme: scheme, Host: authority, Path: endpoint}).String() } // localProbeClient deliberately does not verify the panel's certificate. An @@ -255,6 +259,12 @@ func isLocalHost(host string) bool { return true } + // A link-local address carries the interface to reach it through, which + // neither parses as part of the address nor changes where it leads. + if zone := strings.IndexByte(host, '%'); zone >= 0 { + host = host[:zone] + } + ip := net.ParseIP(host) if ip == nil { return false diff --git a/internal/pkg/panel/install_internal_test.go b/internal/pkg/panel/install_internal_test.go index 1005f17..54c151c 100644 --- a/internal/pkg/panel/install_internal_test.go +++ b/internal/pkg/panel/install_internal_test.go @@ -2,6 +2,7 @@ package panel import ( "net" + "net/http" "testing" "github.com/gameap/gameapctl/pkg/utils" @@ -24,10 +25,24 @@ func Test_createHealthURL(t *testing.T) { {name: "ipv6_custom_port", host: "::1", port: "8025", expected: "http://[::1]:8025/api/health"}, {name: "ipv6_default_port", host: "2a01:4f9:c015:fafb::1", port: "80", expected: "http://[2a01:4f9:c015:fafb::1]/api/health"}, {name: "bracketed_ipv6", host: "[::1]", port: "8025", expected: "http://[::1]:8025/api/health"}, + { + name: "zoned_ipv6_default_port", host: "fe80::1%eth0", port: "80", + expected: "http://[fe80::1%25eth0]/api/health", + }, + { + name: "zoned_ipv6_custom_port", host: "fe80::1%eth0", port: "8025", + expected: "http://[fe80::1%25eth0]:8025/api/health", + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - assert.Equal(t, test.expected, createHealthURL(test.host, test.port, test.https, "/api/health")) + healthURL := createHealthURL(test.host, test.port, test.https, "/api/health") + + assert.Equal(t, test.expected, healthURL) + + // A URL a request cannot be built from never reaches the panel. + _, err := http.NewRequest(http.MethodGet, healthURL, nil) //nolint:noctx // parsing only + assert.NoError(t, err) }) } } @@ -45,6 +60,8 @@ func Test_isLocalHost(t *testing.T) { {name: "unspecified_ipv6", host: "::", expected: true}, {name: "documentation_address", host: "203.0.113.10", expected: false}, {name: "domain", host: "panel.example.com", expected: false}, + {name: "zoned_loopback", host: "::1%lo0", expected: true}, + {name: "zoned_documentation_address", host: "2001:db8::1%eth0", expected: false}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -73,4 +90,8 @@ func Test_isLocalHost_interfaceAddress(t *testing.T) { } assert.True(t, isLocalHost(address)) + + // The panel can be pinned to a link-local address, which is written with the + // interface to reach it through. + assert.True(t, isLocalHost(address+"%eth0")) }