diff --git a/api/http_client.go b/api/http_client.go index 078a2a86c8a..833db1d6ab6 100644 --- a/api/http_client.go +++ b/api/http_client.go @@ -15,6 +15,7 @@ import ( type tokenGetter interface { ActiveToken(string) (string, string) + HostForAPIHost(string) (string, bool) } type HTTPClientOptions struct { @@ -161,7 +162,18 @@ func AddAuthTokenHeader(rt http.RoundTripper, cfg tokenGetter) http.RoundTripper // If the host has changed during a redirect do not add the authentication token header. if !redirectHostnameChange { hostname := ghauth.NormalizeHostname(getHost(req)) - if token, _ := cfg.ActiveToken(hostname); token != "" { + token, _ := cfg.ActiveToken(hostname) + if token == "" { + // The request may be aimed at a host's api_host, which gh is + // not logged in to and so has no token of its own. Fall back + // to the token of the host it stands in for. This only ever + // adds a token where there would have been none, so hosts we + // already authenticate keep resolving exactly as before. + if canonicalHost, ok := cfg.HostForAPIHost(hostname); ok { + token, _ = cfg.ActiveToken(canonicalHost) + } + } + if token != "" { req.Header.Set(authorization, fmt.Sprintf("token %s", token)) } } diff --git a/api/http_client_test.go b/api/http_client_test.go index 56be00af6b0..99bf911a069 100644 --- a/api/http_client_test.go +++ b/api/http_client_test.go @@ -62,6 +62,61 @@ func TestNewHTTPClient(t *testing.T) { }, wantStderr: "", }, + { + name: "api_host is sent the token of the host it stands in for", + args: args{ + config: tinyConfig{ + "github.com:oauth_token": "MYTOKEN", + "api_host:gateway.internal": "github.com", + }, + appVersion: "v1.2.3", + }, + host: "gateway.internal", + wantHeader: map[string][]string{ + "authorization": {"token MYTOKEN"}, + "user-agent": {"GitHub CLI v1.2.3"}, + "x-github-api-version": {"2022-11-28"}, + "accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"}, + }, + wantStderr: "", + }, + { + name: "a host with its own token is unaffected by an api_host mapping", + args: args{ + config: tinyConfig{ + "github.com:oauth_token": "MYTOKEN", + "gateway.internal:oauth_token": "OWNTOKEN", + "api_host:gateway.internal": "github.com", + }, + appVersion: "v1.2.3", + }, + host: "gateway.internal", + wantHeader: map[string][]string{ + "authorization": {"token OWNTOKEN"}, + "user-agent": {"GitHub CLI v1.2.3"}, + "x-github-api-version": {"2022-11-28"}, + "accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"}, + }, + wantStderr: "", + }, + { + name: "an unmapped host is still sent no token", + args: args{ + config: tinyConfig{ + "github.com:oauth_token": "MYTOKEN", + "api_host:gateway.internal": "github.com", + }, + appVersion: "v1.2.3", + }, + host: "elsewhere.internal", + wantHeader: map[string][]string{ + "authorization": nil, // should not be set + "user-agent": {"GitHub CLI v1.2.3"}, + "x-github-api-version": {"2022-11-28"}, + "accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"}, + }, + wantStderr: "", + }, { name: "github.com no authentication token", args: args{ @@ -444,6 +499,13 @@ func (c tinyConfig) ActiveToken(host string) (string, string) { return c[fmt.Sprintf("%s:%s", host, "oauth_token")], "oauth_token" } +// HostForAPIHost resolves via an "api_host:" key holding the host that +// configured it, mirroring the reverse lookup the real config performs. +func (c tinyConfig) HostForAPIHost(apiHost string) (string, bool) { + host, ok := c[fmt.Sprintf("%s:%s", "api_host", apiHost)] + return host, ok +} + var requestAtRE = regexp.MustCompile(`(?m)^\* Request at .+`) var dateRE = regexp.MustCompile(`(?m)^< Date: .+`) var hostWithPortRE = regexp.MustCompile(`127\.0\.0\.1:\d+`) diff --git a/go.mod b/go.mod index cf524926e81..6671970ef54 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/cli/go-gh/v2 v2.13.0 + github.com/cli/go-gh/v2 v2.13.1-0.20260731153212-dfdeaa076f09 github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 github.com/cli/oauth v1.2.2 github.com/cli/safeexec v1.0.1 @@ -73,7 +73,7 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect - github.com/alecthomas/chroma/v2 v2.19.0 // indirect + github.com/alecthomas/chroma/v2 v2.27.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect @@ -98,7 +98,7 @@ require ( github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect - github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/docker/cli v29.5.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -138,8 +138,8 @@ require ( github.com/huandu/xstrings v1.5.0 // indirect github.com/in-toto/in-toto-golang v0.11.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/itchyny/gojq v0.12.17 // indirect - github.com/itchyny/timefmt-go v0.1.6 // indirect + github.com/itchyny/gojq v0.12.19 // indirect + github.com/itchyny/timefmt-go v0.1.8 // indirect github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect @@ -169,7 +169,7 @@ require ( github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/thlib/go-timezone-local v0.0.6 // indirect + github.com/thlib/go-timezone-local v0.0.7 // indirect github.com/transparency-dev/formats v0.1.1 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect diff --git a/go.sum b/go.sum index 8b17ff0761f..28f6ac7ac44 100644 --- a/go.sum +++ b/go.sum @@ -54,10 +54,10 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.19.0 h1:Im+SLRgT8maArxv81mULDWN8oKxkzboH07CHesxElq4= -github.com/alecthomas/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -145,8 +145,8 @@ github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8 github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q= github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= -github.com/cli/go-gh/v2 v2.13.0 h1:jEHZu/VPVoIJkciK3pzZd3rbT8J90swsK5Ui4ewH1ys= -github.com/cli/go-gh/v2 v2.13.0/go.mod h1:Us/NbQ8VNM0fdaILgoXSz6PKkV5PWaEzkJdc9vR2geM= +github.com/cli/go-gh/v2 v2.13.1-0.20260731153212-dfdeaa076f09 h1:LGiLgWXvfbjomdTAEFWPdX4HUkN0r+JV95guuMfSm8w= +github.com/cli/go-gh/v2 v2.13.1-0.20260731153212-dfdeaa076f09/go.mod h1:rabojrPy/l48e2MDmQP+7FDjJalu5fL0wDDfcorZxJg= github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 h1:QDrhR4JA2n3ij9YQN0u5ZeuvRIIvsUGmf5yPlTS0w8E= github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24/go.mod h1:rr9GNING0onuVw8MnracQHn7PcchnFlP882Y0II2KZk= github.com/cli/oauth v1.2.2 h1:/qG/wok8jzu66tx7q+duGOIp4DT5P/ACXrdc33UoNUQ= @@ -185,8 +185,8 @@ github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53 github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= -github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs= github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= @@ -341,10 +341,10 @@ github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ay github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= -github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= -github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= -github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= +github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= +github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY= +github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI= +github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 h1:FWpSWRD8FbVkKQu8M1DM9jF5oXFLyE+XpisIYfdzbic= github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7/go.mod h1:BMxO138bOokdgt4UaxZiEfypcSHX0t6SIFimVP1oRfk= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= @@ -498,8 +498,8 @@ github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qv github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh+AXUf85is6nJk= github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= -github.com/thlib/go-timezone-local v0.0.6 h1:Ii3QJ4FhosL/+eCZl6Hsdr4DDU4tfevNoV83yAEo2tU= -github.com/thlib/go-timezone-local v0.0.6/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= +github.com/thlib/go-timezone-local v0.0.7 h1:fX8zd3aJydqLlTs/TrROrIIdztzsdFV23OzOQx31jII= +github.com/thlib/go-timezone-local v0.0.7/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= github.com/tink-crypto/tink-go-awskms/v3 v3.0.0 h1:XSohRhCkXAVI0iaCnWB/GS05TEmpnKurQmzaY1jzt3Y= github.com/tink-crypto/tink-go-awskms/v3 v3.0.0/go.mod h1:+7MXsShLzVbSQ6dI0Pe4JuZM52jD1jQ1itAygd/MDsA= github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= diff --git a/internal/authflow/flow.go b/internal/authflow/flow.go index 0a195168f26..af20e941c89 100644 --- a/internal/authflow/flow.go +++ b/internal/authflow/flow.go @@ -123,6 +123,12 @@ func (c cfg) ActiveToken(hostname string) (string, string) { return c.token, "oauth_token" } +// HostForAPIHost never resolves, because the token here is supplied directly by +// the login flow and is used for whatever host the request is aimed at. +func (c cfg) HostForAPIHost(string) (string, bool) { + return "", false +} + func getViewer(httpClient *http.Client, hostname, token string) (string, error) { authedClient := *httpClient authedClient.Transport = api.AddAuthTokenHeader(httpClient.Transport, cfg{token: token}) diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index ca5f7e584cb..dee40a42427 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -947,3 +947,85 @@ func preMigrationLogin(c *AuthConfig, hostname, username, token, gitProtocol str } return insecureStorageUsed, ghConfig.Write(c.cfg) } + +func TestHostForAPIHost(t *testing.T) { + tests := []struct { + name string + apiHosts map[string]string + lookup string + wantHost string + wantFound bool + }{ + { + name: "no hosts configure an api_host", + lookup: "api.example.com", + wantFound: false, + }, + { + name: "a host configures the api_host", + apiHosts: map[string]string{"github.com": "api.example.com"}, + lookup: "api.example.com", + wantHost: "github.com", + wantFound: true, + }, + { + name: "matching is case insensitive", + apiHosts: map[string]string{"github.com": "API.example.com"}, + lookup: "api.example.com", + wantHost: "github.com", + wantFound: true, + }, + { + name: "an unrelated api_host does not match", + apiHosts: map[string]string{"github.com": "api.example.com"}, + lookup: "api.other.com", + wantFound: false, + }, + { + name: "an empty lookup matches nothing", + apiHosts: map[string]string{"github.com": "api.example.com"}, + lookup: "", + wantFound: false, + }, + { + name: "the right host is chosen when several configure an api_host", + apiHosts: map[string]string{"github.com": "api.example.com", "ghe.io": "api.ghe.io"}, + lookup: "api.ghe.io", + wantHost: "ghe.io", + wantFound: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authCfg := newTestAuthConfig(t) + hosts := make([]string, 0, len(tt.apiHosts)) + for host, apiHost := range tt.apiHosts { + _, err := authCfg.Login(host, "test-user", "test-token", "https", false) + require.NoError(t, err) + authCfg.cfg.Set([]string{hostsKey, host, apiHostKey}, apiHost) + hosts = append(hosts, host) + } + authCfg.SetHosts(hosts) + + host, found := authCfg.HostForAPIHost(tt.lookup) + + require.Equal(t, tt.wantFound, found) + require.Equal(t, tt.wantHost, host) + }) + } +} + +func TestHostForAPIHostIgnoresHostsWithoutAnAPIHost(t *testing.T) { + // Given a host that is logged in but sets no api_host + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "test-user", "test-token", "https", false) + require.NoError(t, err) + authCfg.SetHosts([]string{"github.com"}) + + // When we look up the empty api_host it configures + _, found := authCfg.HostForAPIHost("") + + // Then it does not match, rather than matching every host + require.False(t, found) +} diff --git a/internal/config/config.go b/internal/config/config.go index dadfa284b30..6ec5ab9f279 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "slices" + "strings" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/keyring" @@ -22,7 +23,8 @@ const ( aliasesKey = "aliases" browserKey = "browser" // used by cli/go-gh to open URLs in web browsers colorLabelsKey = "color_labels" - editorKey = "editor" // used by cli/go-gh to open interactive text editor + apiHostKey = "api_host" // used by cli/go-gh to redirect API requests for a host + editorKey = "editor" // used by cli/go-gh to open interactive text editor gitProtocolKey = "git_protocol" hostsKey = "hosts" // used by cli/go-gh to locate authenticated host tokens httpUnixSocketKey = "http_unix_socket" @@ -327,6 +329,34 @@ func (c *AuthConfig) Hosts() []string { return ghauth.KnownHosts() } +// HostForAPIHost returns the configured host whose api_host points at apiHost, +// reporting false when no host claims it. +// +// go-gh sends API requests for a host to that host's api_host, which is a +// hostname gh is not otherwise logged in to. Callers that resolve credentials +// from a request URL need this to get back to the host the request is really +// for. It answers only the mapping question; deciding whether a given lookup +// should honour api_host at all is the caller's business, since api_host covers +// API traffic and not, say, git operations. +// +// A misconfiguration where several hosts share one api_host resolves to the +// first match in Hosts order. +func (c *AuthConfig) HostForAPIHost(apiHost string) (string, bool) { + if apiHost == "" { + return "", false + } + for _, host := range c.Hosts() { + configured, err := c.cfg.Get([]string{hostsKey, host, apiHostKey}) + if err != nil || configured == "" { + continue + } + if strings.EqualFold(configured, apiHost) { + return host, true + } + } + return "", false +} + // SetHosts will override any hosts resolution and return the given // hosts for all calls to Hosts. Use for testing purposes only. func (c *AuthConfig) SetHosts(hosts []string) { diff --git a/internal/gh/gh.go b/internal/gh/gh.go index 759a931f2b7..f35a10f3122 100644 --- a/internal/gh/gh.go +++ b/internal/gh/gh.go @@ -131,6 +131,10 @@ type AuthConfig interface { // Hosts retrieves a list of known hosts. Hosts() []string + // HostForAPIHost returns the known host whose api_host is the given hostname, + // reporting false when no host claims it. See config.AuthConfig.HostForAPIHost. + HostForAPIHost(apiHost string) (host string, found bool) + // DefaultHost retrieves the default host. DefaultHost() (host string, source string) diff --git a/script/api-host-gateway/README.md b/script/api-host-gateway/README.md new file mode 100644 index 00000000000..b408e5cea00 --- /dev/null +++ b/script/api-host-gateway/README.md @@ -0,0 +1,114 @@ +# `api_host` gateway test + +A black box test for routing `gh` API traffic through a corporate gateway, as +proposed in [cli/cli#13717](https://github.com/cli/cli/issues/13717) and +implemented for go-gh in [cli/go-gh#275](https://github.com/cli/go-gh/pull/275). + +It runs the real `gh` binary against a recording TLS reverse proxy that forwards +to the real api.github.com, with `api.github.com` blackholed so `gh` has no way +to reach GitHub except through the gateway. It then asserts both halves of the +claim: the gateway saw the requests, and `gh` got real answers back. + +## Running it + +```console +$ script/api-host-gateway/run.sh +``` + +Requires Docker and a token: `$GH_TOKEN` if set, otherwise +`gh auth token --hostname github.com`. The token's account must match the login +the test expects, which defaults to `williammartin` and can be overridden with +`GH_APIHOST_EXPECTED_LOGIN`. + +## Why a container + +Two constraints make this awkward to run directly on a developer machine, and +trivial inside a Linux container: + +- `api_host` is a bare hostname, so it cannot carry a port. The gateway has to + listen on 443, which needs root. +- The gateway's certificate has to be trusted by `gh`. Go honours + `SSL_CERT_FILE` on Linux but not on macOS, where it uses the platform + verifier, so on macOS the only alternative would be installing a CA into the + keychain. + +The container also gives us a writable `/etc/hosts`, which is how +`api.github.com` gets blackholed. + +## What it does + +`run.sh` starts `golang:1.26` with the repository mounted at `/src` and runs +`test.sh` inside it. `test.sh`: + +1. Builds `gh` and the gateway. +2. Resolves `api.github.com` to an IP and starts the gateway on + `127.0.0.2:443`, pinned to that IP so it keeps working after the blackhole + goes in. The gateway generates its own CA and leaf certificate for + `gh-gateway.internal`. +3. Trusts that CA through `SSL_CERT_FILE`, and points `gh-gateway.internal` at + `127.0.0.2` in `/etc/hosts`. +4. Writes an isolated `GH_CONFIG_DIR` whose `hosts.yml` has `github.com` with a + `user`, an `oauth_token`, and `api_host: gh-gateway.internal`. +5. Runs three phases of assertions. + +### Phases + +**Phase 1, routed.** `api_host` is set and `api.github.com` is blackholed. Each +of `gh api user`, `gh api repos/cli/cli`, `gh api graphql`, `gh repo view` and +`gh api --paginate` must return real GitHub data, and the gateway must have +recorded the matching request with `Host: gh-gateway.internal` and an +`Authorization` header. The paginated case additionally proves the gateway's +`Link` header rewriting works, because the second page can only be fetched if +`gh` was sent back to the gateway rather than to `api.github.com`, and that the +follow-up request still carries the token. That last assertion is easy to fail: +`gh` attaches tokens by request host, and the gateway host has no token of its +own, so a naive implementation paginates anonymously and only appears to work +against public resources. + +**Phase 2, control.** No `api_host` and no blackhole. The same commands must +still work and the gateway must record nothing, so the override is what causes +the routing. + +**Phase 3, blackhole sanity.** No `api_host`, blackhole back on. `gh api user` +must fail. Without this, phase 1 could pass through a direct connection that the +blackhole was silently failing to prevent. + +## The gateway + +`gateway/main.go` is a single dependency-free program. Beyond recording +requests, it buffers each response and rewrites every occurrence of +`api.github.com` to `gh-gateway.internal`, in headers such as `Link` and in JSON +bodies. That mirrors how this is handled in practice: GitHub returns absolute +URLs on the canonical host, so a gateway that does not rewrite them sends +clients straight back off its route. It asks the upstream for an identity +content encoding so the body is rewritable, and fixes `Content-Length` +afterwards. + +## Debugging the gateway on its own + +The gateway does not need root or a container if you give it an unprivileged +port, which makes it easy to poke at with `curl`: + +```console +$ go build -o /tmp/gateway ./script/api-host-gateway/gateway +$ /tmp/gateway -listen 127.0.0.1:8443 \ + -upstream-addr "$(dig +short api.github.com | head -1):443" \ + -ca-out /tmp/ca.pem -log /tmp/gateway.jsonl & +$ curl --cacert /tmp/ca.pem --resolve gh-gateway.internal:8443:127.0.0.1 \ + -H "Authorization: token $(gh auth token)" \ + https://gh-gateway.internal:8443/user +``` + +`gh` itself cannot be pointed at that, because `api_host` cannot carry a port. + +## Expected result today +Phase 1 fails and phases 2 and 3 pass. `gh` builds its own REST and GraphQL +endpoints in `internal/ghinstance` and passes `Host: "none"` to go-gh's +`NewHTTPClient`, so it never consults `api_host` and cannot reach the blackholed +canonical host. That red result is the point: it is the acceptance criterion for +teaching `gh` itself about `api_host`. + +The assertions have been confirmed to be satisfiable. A throwaway spike that +swapped the request host in `api.NewHTTPClient` and attached the canonical +host's token for the configured API host turned all of phase 1 green, including +the paginated case. diff --git a/script/api-host-gateway/gateway/main.go b/script/api-host-gateway/gateway/main.go new file mode 100644 index 00000000000..b084803cff2 --- /dev/null +++ b/script/api-host-gateway/gateway/main.go @@ -0,0 +1,315 @@ +// Command gateway is a recording TLS reverse proxy that stands in for a +// corporate API gateway during the api_host black box test. +// +// It terminates TLS with a certificate it generates for itself, forwards every +// request to the real GitHub API, and appends a JSONL record for each request +// it handled. Requests are forwarded to a pinned upstream address so the +// gateway keeps working after the test blackholes api.github.com in +// /etc/hosts. +// +// Responses are buffered and every occurrence of the upstream host is rewritten +// to the gateway host, in headers such as Link and in JSON bodies. Real +// gateways have to do this because GitHub does not rewrite the absolute URLs it +// returns, so without it a paginated request would send the client straight +// back to the canonical host. +package main + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "flag" + "fmt" + "io" + "log" + "math/big" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" +) + +type recordKey struct{} + +// record is a single line of the gateway's JSONL request log. Host is the Host +// header as the client sent it, before the gateway rewrites it for the +// upstream, so it is evidence of where the client believed it was connecting. +type record struct { + Time string `json:"time"` + Method string `json:"method"` + Path string `json:"path"` + Host string `json:"host"` + AuthHeader bool `json:"auth_header"` + Status int `json:"status"` + Error string `json:"error,omitempty"` +} + +func main() { + listen := flag.String("listen", "127.0.0.2:443", "address to listen on, must be port 443 because api_host cannot carry a port") + gatewayHost := flag.String("gateway-host", "gh-gateway.internal", "hostname clients use to reach this gateway") + upstreamHost := flag.String("upstream-host", "api.github.com", "canonical GitHub API host to forward to") + upstreamAddr := flag.String("upstream-addr", "", "pinned host:port to dial for the upstream, bypassing DNS") + caOut := flag.String("ca-out", "", "path to write the generated CA certificate to") + logOut := flag.String("log", "", "path to append JSONL request records to") + readyOut := flag.String("ready", "", "path to create once the gateway is accepting connections") + flag.Parse() + + if err := run(*listen, *gatewayHost, *upstreamHost, *upstreamAddr, *caOut, *logOut, *readyOut); err != nil { + log.Fatalf("gateway: %v", err) + } +} + +func run(listen, gatewayHost, upstreamHost, upstreamAddr, caOut, logOut, readyOut string) error { + for name, value := range map[string]string{ + "-upstream-addr": upstreamAddr, + "-ca-out": caOut, + "-log": logOut, + } { + if value == "" { + return fmt.Errorf("%s is required", name) + } + } + + rec, err := newRecorder(logOut) + if err != nil { + return fmt.Errorf("opening log: %w", err) + } + defer rec.Close() + + proxy := newProxy(gatewayHost, &url.URL{Scheme: "https", Host: upstreamHost}, upstreamAddr, rec) + + // Bind before writing the CA out, so a gateway that cannot start does not + // replace the CA that a running one is serving with. + tcpListener, err := net.Listen("tcp", listen) + if err != nil { + return fmt.Errorf("listening on %s: %w", listen, err) + } + + caPEM, serverCert, err := generateCertificates(gatewayHost) + if err != nil { + return fmt.Errorf("generating certificates: %w", err) + } + if err := os.WriteFile(caOut, caPEM, 0o600); err != nil { + return fmt.Errorf("writing CA certificate: %w", err) + } + + tlsListener := tls.NewListener(tcpListener, &tls.Config{ + Certificates: []tls.Certificate{serverCert}, + MinVersion: tls.VersionTLS12, + }) + + if readyOut != "" { + if err := os.WriteFile(readyOut, []byte(listen), 0o600); err != nil { + return fmt.Errorf("writing ready file: %w", err) + } + } + + log.Printf("gateway listening on %s as %s, forwarding to %s at %s", listen, gatewayHost, upstreamHost, upstreamAddr) + + server := &http.Server{ + Handler: recordingHandler(proxy, rec), + ReadHeaderTimeout: 30 * time.Second, + } + return server.Serve(tlsListener) +} + +// recordingHandler attaches a record to the request context so the proxy can +// fill in the outcome, then writes exactly one line per request once the proxy +// is done with it. +func recordingHandler(proxy http.Handler, rec *recorder) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + entry := &record{ + Time: time.Now().UTC().Format(time.RFC3339Nano), + Method: r.Method, + Path: r.URL.RequestURI(), + Host: r.Host, + AuthHeader: r.Header.Get("Authorization") != "", + } + proxy.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), recordKey{}, entry))) + rec.Write(entry) + }) +} + +// newProxy builds the reverse proxy. The upstream URL carries the canonical +// host that requests are forwarded to and that responses are rewritten from, +// while dialAddr is the address actually dialled, so the gateway keeps working +// once the canonical host is blackholed. +func newProxy(gatewayHost string, upstream *url.URL, dialAddr string, rec *recorder) *httputil.ReverseProxy { + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, network, dialAddr) + }, + TLSClientConfig: &tls.Config{ + ServerName: upstream.Host, + MinVersion: tls.VersionTLS12, + }, + ForceAttemptHTTP2: true, + } + + return &httputil.ReverseProxy{ + Transport: transport, + Rewrite: func(pr *httputil.ProxyRequest) { + pr.Out.URL.Scheme = upstream.Scheme + pr.Out.URL.Host = upstream.Host + pr.Out.Host = upstream.Host + // The response has to be readable for rewriting, and asking for an + // identity encoding is cheaper than decompressing it again. + pr.Out.Header.Set("Accept-Encoding", "identity") + }, + ModifyResponse: func(resp *http.Response) error { + if entry, ok := resp.Request.Context().Value(recordKey{}).(*record); ok { + entry.Status = resp.StatusCode + } + return rewriteResponse(resp, upstream.Host, gatewayHost) + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + if entry, ok := r.Context().Value(recordKey{}).(*record); ok { + entry.Status = http.StatusBadGateway + entry.Error = err.Error() + } + rec.Logf("upstream error for %s %s: %v", r.Method, r.URL.RequestURI(), err) + w.WriteHeader(http.StatusBadGateway) + fmt.Fprintf(w, "gateway: upstream error: %v\n", err) + }, + } +} + +// rewriteResponse replaces the upstream host with the gateway host everywhere +// it appears, so that clients following URLs the API handed them stay on the +// gateway. +func rewriteResponse(resp *http.Response, upstreamHost, gatewayHost string) error { + for key, values := range resp.Header { + for i, value := range values { + if strings.Contains(value, upstreamHost) { + values[i] = strings.ReplaceAll(value, upstreamHost, gatewayHost) + } + } + resp.Header[key] = values + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading upstream body: %w", err) + } + if err := resp.Body.Close(); err != nil { + return fmt.Errorf("closing upstream body: %w", err) + } + + body = bytes.ReplaceAll(body, []byte(upstreamHost), []byte(gatewayHost)) + + resp.Body = io.NopCloser(bytes.NewReader(body)) + resp.ContentLength = int64(len(body)) + resp.Header.Set("Content-Length", strconv.Itoa(len(body))) + // The upstream was asked for an identity encoding, but drop any stale + // encoding header rather than describe the rewritten body incorrectly. + resp.Header.Del("Content-Encoding") + + return nil +} + +func generateCertificates(gatewayHost string) ([]byte, tls.Certificate, error) { + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, tls.Certificate{}, err + } + + notBefore := time.Now().Add(-time.Hour) + notAfter := time.Now().Add(24 * time.Hour) + + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "gh api_host test CA"}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + IsCA: true, + } + + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + if err != nil { + return nil, tls.Certificate{}, err + } + caCert, err := x509.ParseCertificate(caDER) + if err != nil { + return nil, tls.Certificate{}, err + } + + serverKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, tls.Certificate{}, err + } + + serverTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: gatewayHost}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{gatewayHost}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv4(127, 0, 0, 2)}, + } + + serverDER, err := x509.CreateCertificate(rand.Reader, serverTemplate, caCert, &serverKey.PublicKey, caKey) + if err != nil { + return nil, tls.Certificate{}, err + } + + caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}) + + return caPEM, tls.Certificate{ + Certificate: [][]byte{serverDER, caDER}, + PrivateKey: serverKey, + }, nil +} + +type recorder struct { + mu sync.Mutex + file *os.File +} + +func newRecorder(path string) (*recorder, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return nil, err + } + return &recorder{file: file}, nil +} + +func (r *recorder) Write(entry *record) { + line, err := json.Marshal(entry) + if err != nil { + r.Logf("marshalling record: %v", err) + return + } + + r.mu.Lock() + defer r.mu.Unlock() + if _, err := r.file.Write(append(line, '\n')); err != nil { + log.Printf("gateway: writing record: %v", err) + } +} + +func (r *recorder) Logf(format string, args ...any) { + log.Printf("gateway: "+format, args...) +} + +func (r *recorder) Close() { + if err := r.file.Close(); err != nil { + log.Printf("gateway: closing log: %v", err) + } +} diff --git a/script/api-host-gateway/gateway/main_test.go b/script/api-host-gateway/gateway/main_test.go new file mode 100644 index 00000000000..7ceb1132fe8 --- /dev/null +++ b/script/api-host-gateway/gateway/main_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "crypto/x509" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGatewayForwardsRewritesAndRecords(t *testing.T) { + var upstreamHostHeader, upstreamAuthHeader, upstreamAcceptEncoding string + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamHostHeader = r.Host + upstreamAuthHeader = r.Header.Get("Authorization") + upstreamAcceptEncoding = r.Header.Get("Accept-Encoding") + + w.Header().Set("Link", `; rel="next"`) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"url":"https://api.github.com/repos/cli/cli"}`) + })) + defer upstream.Close() + + logPath := filepath.Join(t.TempDir(), "gateway.jsonl") + rec, err := newRecorder(logPath) + require.NoError(t, err) + defer rec.Close() + + proxy := newProxy( + "gh-gateway.internal", + &url.URL{Scheme: "http", Host: "api.github.com"}, + strings.TrimPrefix(upstream.URL, "http://"), + rec, + ) + + gateway := httptest.NewServer(recordingHandler(proxy, rec)) + defer gateway.Close() + + req, err := http.NewRequest(http.MethodGet, gateway.URL+"/repos/cli/cli", nil) + require.NoError(t, err) + req.Host = "gh-gateway.internal" + req.Header.Set("Authorization", "token secret") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + t.Run("forwards to the upstream as the canonical host", func(t *testing.T) { + assert.Equal(t, "api.github.com", upstreamHostHeader) + assert.Equal(t, "token secret", upstreamAuthHeader) + assert.Equal(t, "identity", upstreamAcceptEncoding) + }) + + t.Run("rewrites the canonical host out of headers and body", func(t *testing.T) { + assert.Equal(t, `; rel="next"`, resp.Header.Get("Link")) + assert.Equal(t, `{"url":"https://gh-gateway.internal/repos/cli/cli"}`, string(body)) + assert.Equal(t, strconv.Itoa(len(body)), resp.Header.Get("Content-Length")) + }) + + t.Run("records the request as the client addressed it", func(t *testing.T) { + entries := readLog(t, logPath) + require.Len(t, entries, 1) + assert.Equal(t, http.MethodGet, entries[0].Method) + assert.Equal(t, "/repos/cli/cli", entries[0].Path) + assert.Equal(t, "gh-gateway.internal", entries[0].Host) + assert.True(t, entries[0].AuthHeader) + assert.Equal(t, http.StatusOK, entries[0].Status) + }) +} + +func TestGatewayRecordsUpstreamFailures(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "gateway.jsonl") + rec, err := newRecorder(logPath) + require.NoError(t, err) + defer rec.Close() + + // 127.0.0.1:1 is not listening, standing in for an unreachable upstream. + proxy := newProxy("gh-gateway.internal", &url.URL{Scheme: "http", Host: "api.github.com"}, "127.0.0.1:1", rec) + + gateway := httptest.NewServer(recordingHandler(proxy, rec)) + defer gateway.Close() + + resp, err := http.Get(gateway.URL + "/user") + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) + + entries := readLog(t, logPath) + require.Len(t, entries, 1) + assert.Equal(t, "/user", entries[0].Path) + assert.False(t, entries[0].AuthHeader) + assert.NotEmpty(t, entries[0].Error) +} + +func TestGeneratedServerCertificateChainsToTheCA(t *testing.T) { + caPEM, serverCert, err := generateCertificates("gh-gateway.internal") + require.NoError(t, err) + + roots := x509.NewCertPool() + require.True(t, roots.AppendCertsFromPEM(caPEM)) + + leaf, err := x509.ParseCertificate(serverCert.Certificate[0]) + require.NoError(t, err) + + _, err = leaf.Verify(x509.VerifyOptions{ + Roots: roots, + DNSName: "gh-gateway.internal", + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }) + require.NoError(t, err) + + _, err = leaf.Verify(x509.VerifyOptions{ + Roots: roots, + DNSName: "api.github.com", + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }) + require.Error(t, err, "the gateway must not be able to impersonate the canonical host") +} + +func readLog(t *testing.T, path string) []record { + t.Helper() + + contents, err := os.ReadFile(path) + require.NoError(t, err) + + var entries []record + for _, line := range strings.Split(strings.TrimSpace(string(contents)), "\n") { + if line == "" { + continue + } + var entry record + require.NoError(t, json.Unmarshal([]byte(line), &entry)) + entries = append(entries, entry) + } + return entries +} diff --git a/script/api-host-gateway/run.sh b/script/api-host-gateway/run.sh new file mode 100755 index 00000000000..b40c0f212b0 --- /dev/null +++ b/script/api-host-gateway/run.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Entry point for the api_host black box test. Runs the test inside a Linux +# container because it needs root to bind port 443 and needs Go to honour +# SSL_CERT_FILE, which it does not do on macOS. See README.md. + +set -euo pipefail + +IMAGE=${GH_APIHOST_IMAGE:-golang:1.26} +MODULE_CACHE_VOLUME=gh-api-host-gateway-gomodcache +BUILD_CACHE_VOLUME=gh-api-host-gateway-gobuildcache + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) + +command -v docker >/dev/null 2>&1 || { + printf 'error: docker is required\n' >&2 + exit 1 +} + +token=${GH_TOKEN:-} +if [ -z "$token" ]; then + token=$(gh auth token --hostname github.com) || { + printf 'error: set GH_TOKEN or run gh auth login for github.com\n' >&2 + exit 1 + } +fi + +docker volume create "$MODULE_CACHE_VOLUME" >/dev/null +docker volume create "$BUILD_CACHE_VOLUME" >/dev/null + +exec docker run --rm -t \ + -v "$repo_root:/src:ro" \ + -v "$MODULE_CACHE_VOLUME:/go/pkg/mod" \ + -v "$BUILD_CACHE_VOLUME:/root/.cache/go-build" \ + -e GH_APIHOST_TOKEN="$token" \ + -e GH_APIHOST_EXPECTED_LOGIN="${GH_APIHOST_EXPECTED_LOGIN:-williammartin}" \ + -w /src \ + "$IMAGE" \ + /src/script/api-host-gateway/test.sh diff --git a/script/api-host-gateway/test.sh b/script/api-host-gateway/test.sh new file mode 100755 index 00000000000..dd9be351630 --- /dev/null +++ b/script/api-host-gateway/test.sh @@ -0,0 +1,288 @@ +#!/usr/bin/env bash +# Black box test for per-host api_host routing, run inside the container that +# run.sh starts. See README.md for why this needs a container. +# +# The test proves three things: +# 1. With api_host set, gh sends its API traffic to the gateway and still gets +# real answers from github.com, even though api.github.com is blackholed. +# 2. Without api_host, gh goes straight to api.github.com and the gateway sees +# nothing. +# 3. With the blackhole in place and no api_host, gh cannot reach GitHub at +# all, so phase 1 cannot be passing by accident. + +set -uo pipefail + +GATEWAY_HOST=${GH_APIHOST_GATEWAY_HOST:-gh-gateway.internal} +GATEWAY_IP=127.0.0.2 +UPSTREAM_HOST=api.github.com +REPO=${GH_APIHOST_REPO:-/src} +WORK=${GH_APIHOST_WORK:-/tmp/api-host-gateway} +EXPECTED_LOGIN=${GH_APIHOST_EXPECTED_LOGIN:-williammartin} +TOKEN=${GH_APIHOST_TOKEN:-} + +CONFIG_DIR="$WORK/ghconfig" +LOG="$WORK/gateway.jsonl" +BUNDLE="$WORK/bundle.pem" + +FAILURES=0 +GATEWAY_PID= + +die() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +pass() { + printf ' ok %s\n' "$1" +} + +fail() { + printf ' FAIL %s\n' "$1" + FAILURES=$((FAILURES + 1)) +} + +heading() { + printf '\n== %s\n' "$1" +} + +cleanup() { + if [ -n "$GATEWAY_PID" ]; then + kill "$GATEWAY_PID" 2>/dev/null + fi +} + +# --- setup ------------------------------------------------------------------- + +[ -n "$TOKEN" ] || die "GH_APIHOST_TOKEN is required" +[ "$(id -u)" = "0" ] || die "must run as root to bind port 443 and edit /etc/hosts" + +mkdir -p "$WORK" "$CONFIG_DIR" +chmod 700 "$CONFIG_DIR" +rm -f "$LOG" "$WORK/ready" + +heading "Building gh and the gateway" +# The repository may be mounted from a git worktree, whose .git file points +# outside the mount, so VCS stamping cannot work here. +(cd "$REPO" && go build -buildvcs=false -o "$WORK/gh" ./cmd/gh) || die "building gh" +(cd "$REPO" && go build -buildvcs=false -o "$WORK/gateway" ./script/api-host-gateway/gateway) || die "building the gateway" + +# Resolve the upstream before the blackhole goes in, so the gateway can keep +# reaching GitHub once gh no longer can. +UPSTREAM_IP=$(getent ahostsv4 "$UPSTREAM_HOST" | awk 'NR==1 {print $1}') +[ -n "$UPSTREAM_IP" ] || die "could not resolve $UPSTREAM_HOST" + +heading "Starting the gateway" +"$WORK/gateway" \ + -listen "$GATEWAY_IP:443" \ + -gateway-host "$GATEWAY_HOST" \ + -upstream-host "$UPSTREAM_HOST" \ + -upstream-addr "$UPSTREAM_IP:443" \ + -ca-out "$WORK/ca.pem" \ + -log "$LOG" \ + -ready "$WORK/ready" >"$WORK/gateway.stderr" 2>&1 & +GATEWAY_PID=$! +trap cleanup EXIT + +for _ in $(seq 1 100); do + [ -f "$WORK/ready" ] && break + sleep 0.1 +done +[ -f "$WORK/ready" ] || die "gateway did not start: $(cat "$WORK/gateway.stderr")" +printf 'gateway pid %s, upstream %s at %s\n' "$GATEWAY_PID" "$UPSTREAM_HOST" "$UPSTREAM_IP" + +# Go on Linux honours SSL_CERT_FILE, which is the whole reason this test runs in +# a container. Keep the real roots so anything else gh talks to still works. +cat /etc/ssl/certs/ca-certificates.crt "$WORK/ca.pem" >"$BUNDLE" || die "building the trust bundle" + +grep -q "$GATEWAY_HOST" /etc/hosts || printf '%s %s\n' "$GATEWAY_IP" "$GATEWAY_HOST" >>/etc/hosts + +# --- helpers ----------------------------------------------------------------- + +# write_config yes|no controls whether hosts.yml carries the api_host override. +write_config() { + { + printf '%s:\n' "github.com" + printf ' user: %s\n' "$EXPECTED_LOGIN" + printf ' oauth_token: %s\n' "$TOKEN" + printf ' git_protocol: https\n' + if [ "$1" = "yes" ]; then + printf ' api_host: %s\n' "$GATEWAY_HOST" + fi + printf ' users:\n' + printf ' %s:\n' "$EXPECTED_LOGIN" + printf ' oauth_token: %s\n' "$TOKEN" + } >"$CONFIG_DIR/hosts.yml" + chmod 600 "$CONFIG_DIR/hosts.yml" +} + +# /etc/hosts is a bind mount in a container, so it can only be rewritten in +# place. Appending and truncating work, renaming a temp file over it does not. +blackhole_on() { + grep -q "^127.0.0.1 $UPSTREAM_HOST\$" /etc/hosts || + printf '127.0.0.1 %s\n' "$UPSTREAM_HOST" >>/etc/hosts +} + +blackhole_off() { + local remaining + remaining=$(grep -v "^127.0.0.1 $UPSTREAM_HOST\$" /etc/hosts) + printf '%s\n' "$remaining" >/etc/hosts +} + +run_gh() { + env -u GH_TOKEN -u GITHUB_TOKEN -u GH_HOST -u GH_ENTERPRISE_TOKEN \ + GH_CONFIG_DIR="$CONFIG_DIR" \ + SSL_CERT_FILE="$BUNDLE" \ + GH_NO_UPDATE_NOTIFIER=1 \ + "$WORK/gh" "$@" +} + +# expect_gh +expect_gh() { + local desc=$1 expected=$2 + shift 2 + + local out rc + out=$(run_gh "$@" 2>"$WORK/stderr.txt") + rc=$? + + if [ $rc -ne 0 ]; then + fail "$desc: gh exited $rc: $(tr '\n' ' ' <"$WORK/stderr.txt")" + return 1 + fi + + if [ "$out" = "$expected" ]; then + pass "$desc" + else + fail "$desc: expected [$expected], got [$out]" + fi +} + +# expect_gh_failure +expect_gh_failure() { + local desc=$1 + shift + + local out rc + out=$(run_gh "$@" 2>&1) + rc=$? + + if [ $rc -ne 0 ]; then + pass "$desc" + else + fail "$desc: gh unexpectedly succeeded with [$out]" + fi +} + +# expect_gateway_request +expect_gateway_request() { + local pattern="\"method\":\"$2\",\"path\":\"$3\",\"host\":\"$GATEWAY_HOST\",\"auth_header\":true" + if grep -qF "$pattern" "$LOG" 2>/dev/null; then + pass "$1" + else + fail "$1: no authenticated $2 $3 recorded for $GATEWAY_HOST" + fi +} + +# expect_gateway_request_matching +expect_gateway_request_matching() { + if grep -qE "$2" "$LOG" 2>/dev/null; then + pass "$1" + else + fail "$1: no gateway record matching $2" + fi +} + +expect_gateway_silent() { + local count + count=$(wc -l <"$LOG" 2>/dev/null | tr -d ' ') + if [ "${count:-0}" = "0" ]; then + pass "$1" + else + fail "$1: gateway recorded $count requests" + fi +} + +reset_log() { + : >"$LOG" +} + +# --- phase 1: routed --------------------------------------------------------- + +heading "Phase 1: api_host set, api.github.com blackholed" +write_config yes +blackhole_on +reset_log + +expect_gh "gh api user returns the authenticated login" "$EXPECTED_LOGIN" \ + api user --jq .login +expect_gh "gh api repos/cli/cli returns real repository data" "cli/cli" \ + api repos/cli/cli --jq .full_name +expect_gh "gh api graphql returns the authenticated login" "$EXPECTED_LOGIN" \ + api graphql -f query='query{viewer{login}}' --jq .data.viewer.login +expect_gh "gh repo view returns real repository data" "cli/cli" \ + repo view cli/cli --json nameWithOwner --jq .nameWithOwner + +labels=$(run_gh api --paginate 'repos/cli/cli/labels?per_page=50' --jq '.[].name' 2>"$WORK/stderr.txt") +label_count=$(printf '%s\n' "$labels" | grep -c . ) +if [ "$label_count" -gt 50 ]; then + pass "gh api --paginate followed the rewritten Link header ($label_count labels)" +else + fail "gh api --paginate did not get past the first page (got $label_count labels): $(tr '\n' ' ' <"$WORK/stderr.txt")" +fi + +expect_gateway_request "gateway recorded the authenticated REST request for /user" GET /user +expect_gateway_request "gateway recorded the authenticated REST request for the repository" GET /repos/cli/cli +expect_gateway_request "gateway recorded the authenticated GraphQL requests" POST /graphql +expect_gateway_request_matching "gateway recorded the second page of labels" \ + '"path":"[^"]*page=2[^"]*","host":"'"$GATEWAY_HOST"'"' +# Requests to a gateway-provided URL still have to carry the token, or +# paginating anything private would break. go-gh permits authorization for the +# configured API host for exactly this reason. +expect_gateway_request_matching "second page request carried the token" \ + '"path":"[^"]*page=2[^"]*","host":"'"$GATEWAY_HOST"'","auth_header":true' + +heading "Gateway log for phase 1" +if [ -s "$LOG" ]; then + cat "$LOG" +else + printf '(empty)\n' +fi + +# --- phase 2: control -------------------------------------------------------- + +heading "Phase 2: no api_host, no blackhole" +write_config no +blackhole_off +reset_log + +expect_gh "gh api user still works without an override" "$EXPECTED_LOGIN" \ + api user --jq .login +expect_gh "gh api repos/cli/cli still works without an override" "cli/cli" \ + api repos/cli/cli --jq .full_name +expect_gh "gh api graphql still works without an override" "$EXPECTED_LOGIN" \ + api graphql -f query='query{viewer{login}}' --jq .data.viewer.login +expect_gh "gh repo view still works without an override" "cli/cli" \ + repo view cli/cli --json nameWithOwner --jq .nameWithOwner + +expect_gateway_silent "gateway saw no traffic without an override" + +# --- phase 3: blackhole sanity ---------------------------------------------- + +heading "Phase 3: no api_host, api.github.com blackholed" +blackhole_on +reset_log + +expect_gh_failure "gh cannot reach GitHub directly while blackholed" api user + +blackhole_off + +# --- summary ----------------------------------------------------------------- + +heading "Summary" +if [ "$FAILURES" -eq 0 ]; then + printf 'all assertions passed\n' + exit 0 +fi + +printf '%d assertion(s) failed\n' "$FAILURES" +exit 1