diff --git a/internal/cmd/proxy.go b/internal/cmd/proxy.go index 4d21fe7f..484eca18 100644 --- a/internal/cmd/proxy.go +++ b/internal/cmd/proxy.go @@ -199,7 +199,7 @@ func runProxy(cmd *cobra.Command, args []string) error { if tlsCfg != nil { fmt.Fprintf(os.Stderr, " CA cert: %s\n", tlsCfg.CACertPath) fmt.Fprintf(os.Stderr, "\nConnect with:\n") - fmt.Fprintf(os.Stderr, " export GH_HOST=%s\n", actualAddr) + fmt.Fprintf(os.Stderr, " export GH_HOST=%s\n", clientAddr(actualAddr)) fmt.Fprintf(os.Stderr, " export NODE_EXTRA_CA_CERTS=%s\n", tlsCfg.CACertPath) fmt.Fprintf(os.Stderr, " gh issue list -R org/repo\n\n") } else { @@ -220,3 +220,18 @@ func runProxy(cmd *cobra.Command, args []string) error { return httpServer.Close() } + +// clientAddr returns a client-friendly address from a listener address. +// When the host is a wildcard (0.0.0.0, ::, or empty), it substitutes +// "localhost" so the printed GH_HOST value is usable from a client. +func clientAddr(addr string) string { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return addr + } + switch host { + case "", "0.0.0.0", "::", "[::]": + return net.JoinHostPort("localhost", port) + } + return addr +} diff --git a/internal/cmd/proxy_test.go b/internal/cmd/proxy_test.go new file mode 100644 index 00000000..8a9446a0 --- /dev/null +++ b/internal/cmd/proxy_test.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestClientAddr(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "IPv4 wildcard becomes localhost", + input: "0.0.0.0:8080", + expected: "localhost:8080", + }, + { + name: "IPv6 wildcard :: becomes localhost", + input: "[::]:8443", + expected: "localhost:8443", + }, + { + name: "explicit localhost unchanged", + input: "localhost:3000", + expected: "localhost:3000", + }, + { + name: "explicit 127.0.0.1 unchanged", + input: "127.0.0.1:9090", + expected: "127.0.0.1:9090", + }, + { + name: "non-loopback host unchanged", + input: "192.168.1.1:8080", + expected: "192.168.1.1:8080", + }, + { + name: "invalid address returned as-is", + input: "not-an-addr", + expected: "not-an-addr", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, clientAddr(tc.input)) + }) + } +} diff --git a/internal/proxy/tls.go b/internal/proxy/tls.go index b1b79eb7..7031ba39 100644 --- a/internal/proxy/tls.go +++ b/internal/proxy/tls.go @@ -57,7 +57,7 @@ type TLSConfig struct { func GenerateSelfSignedTLS(dir string) (*TLSConfig, error) { logTLS.Print("generating self-signed TLS certificates for localhost") - if err := os.MkdirAll(dir, 0700); err != nil { + if err := os.MkdirAll(dir, 0755); err != nil { return nil, fmt.Errorf("failed to create TLS directory %s: %w", dir, err) } @@ -134,10 +134,10 @@ func GenerateSelfSignedTLS(dir string) (*TLSConfig, error) { certPath := filepath.Join(dir, "server.crt") keyPath := filepath.Join(dir, "server.key") - if err := writePEM(caCertPath, "CERTIFICATE", caCertDER); err != nil { + if err := writePEM(caCertPath, "CERTIFICATE", caCertDER, 0644); err != nil { return nil, fmt.Errorf("failed to write CA cert: %w", err) } - if err := writePEM(certPath, "CERTIFICATE", serverCertDER); err != nil { + if err := writePEM(certPath, "CERTIFICATE", serverCertDER, 0644); err != nil { return nil, fmt.Errorf("failed to write server cert: %w", err) } @@ -145,7 +145,7 @@ func GenerateSelfSignedTLS(dir string) (*TLSConfig, error) { if err != nil { return nil, fmt.Errorf("failed to marshal server key: %w", err) } - if err := writePEM(keyPath, "EC PRIVATE KEY", serverKeyDER); err != nil { + if err := writePEM(keyPath, "EC PRIVATE KEY", serverKeyDER, 0600); err != nil { return nil, fmt.Errorf("failed to write server key: %w", err) } @@ -181,8 +181,8 @@ func randomSerial() (*big.Int, error) { return serial, nil } -func writePEM(path, blockType string, derBytes []byte) error { - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) +func writePEM(path, blockType string, derBytes []byte, perm os.FileMode) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) if err != nil { return err } diff --git a/internal/proxy/tls_test.go b/internal/proxy/tls_test.go index b9dc3171..9ed90789 100644 --- a/internal/proxy/tls_test.go +++ b/internal/proxy/tls_test.go @@ -3,6 +3,7 @@ package proxy import ( "crypto/tls" "crypto/x509" + "net" "net/http" "net/http/httptest" "os" @@ -96,7 +97,7 @@ func TestGenerateSelfSignedTLS(t *testing.T) { assert.Contains(t, err.Error(), "certificate") }) - t.Run("server cert covers localhost and 127.0.0.1", func(t *testing.T) { + t.Run("server cert covers localhost, 127.0.0.1, and ::1", func(t *testing.T) { dir := t.TempDir() tlsCfg, err := GenerateSelfSignedTLS(dir) require.NoError(t, err) @@ -106,14 +107,18 @@ func TestGenerateSelfSignedTLS(t *testing.T) { require.NoError(t, err) assert.Contains(t, leaf.DNSNames, "localhost") - foundLoopback := false + foundLoopback4 := false + foundLoopback6 := false for _, ip := range leaf.IPAddresses { - if ip.Equal([]byte{127, 0, 0, 1}) || ip.String() == "127.0.0.1" { - foundLoopback = true - break + if ip.Equal(net.IPv4(127, 0, 0, 1)) { + foundLoopback4 = true + } + if ip.Equal(net.IPv6loopback) { + foundLoopback6 = true } } - assert.True(t, foundLoopback, "server cert should cover 127.0.0.1") + assert.True(t, foundLoopback4, "server cert should cover 127.0.0.1") + assert.True(t, foundLoopback6, "server cert should cover ::1") }) t.Run("key files have restricted permissions", func(t *testing.T) { diff --git a/run_containerized.sh b/run_containerized.sh index e395a20b..0dedbf76 100755 --- a/run_containerized.sh +++ b/run_containerized.sh @@ -325,7 +325,7 @@ run_proxy_mode() { local args=("$@") local has_log_dir=false for arg in "$@"; do - if [ "$arg" = "--log-dir" ]; then + if [ "$arg" = "--log-dir" ] || [[ "$arg" == --log-dir=* ]]; then has_log_dir=true break fi