diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ac48bff --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: Test + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + + - name: Configure git + run: | + git config --global user.name "github-actions" + git config --global user.email "github@actions" + + - name: Run tests + run: go test -race -count=1 ./... diff --git a/github.go b/github.go index 4120566..6516584 100644 --- a/github.go +++ b/github.go @@ -5,11 +5,14 @@ package main import ( "context" + "encoding/json" "flag" "fmt" "net/http" "os" "path/filepath" + "strings" + "sync" "time" "github.com/bradleyfalzon/ghinstallation/v2" @@ -17,9 +20,8 @@ import ( ) var ( - gitHubAppID = flag.Int64("github-app-id", 3091475, "GitHub App ID") - gitHubInstallationID = flag.Int64("github-app-install-id", 116358802, "GitHub App installation ID") - setecSecret = flag.String("setec-secret", "prod/rogitproxy/github-app-key-pem", "setec secret name for the GitHub App private key") + gitHubAppID = flag.Int64("github-app-id", 3091475, "GitHub App ID") + setecSecret = flag.String("setec-secret", "prod/rogitproxy/github-app-key-pem", "setec secret name for the GitHub App private key") ) // GitHubAppTransport returns an http.RoundTripper that authenticates @@ -27,48 +29,185 @@ var ( // basic auth (x-access-token:), which is the format GitHub's // git smart HTTP endpoints require. // +// It discovers all installations of the GitHub App at startup and +// caches per-org transports. New org installations are discovered +// lazily on first request. +// // It reads the private key from ~/keys/rogitproxy.pem on disk, // falling back to setec if setecURL is non-empty. -func GitHubAppTransport(ctx context.Context, setecURL string, setecDo func(*http.Request) (*http.Response, error)) (http.RoundTripper, error) { +// +// It returns the transport and the number of org installations +// discovered at startup. +func GitHubAppTransport(ctx context.Context, setecURL string, setecDo func(*http.Request) (*http.Response, error)) (*multiOrgTransport, error) { keyPEM, err := getGitHubAppPrivateKey(ctx, setecURL, setecDo) if err != nil { return nil, err } - appTr, err := ghinstallation.NewAppsTransport(http.DefaultTransport, *gitHubAppID, keyPEM) + appsTr, err := ghinstallation.NewAppsTransport(http.DefaultTransport, *gitHubAppID, keyPEM) if err != nil { return nil, fmt.Errorf("creating GitHub app transport: %w", err) } - itr := ghinstallation.NewFromAppsTransport(appTr, *gitHubInstallationID) - // Verify we can get a token at startup. - if _, err := itr.Token(ctx); err != nil { - return nil, fmt.Errorf("getting initial token: %w", err) + mt := &multiOrgTransport{ + base: http.DefaultTransport, + appsTr: appsTr, + orgTr: make(map[string]*ghinstallation.Transport), } - return &gitBasicAuthTransport{base: http.DefaultTransport, itr: itr}, nil + return mt, nil } -// gitBasicAuthTransport wraps a ghinstallation.Transport and adds -// HTTP basic auth (x-access-token:) to each request. -// This is the format GitHub's git smart HTTP endpoints require, -// as opposed to the "Authorization: token " header that -// ghinstallation uses by default (which works for the REST API -// but not for git HTTP). -type gitBasicAuthTransport struct { - base http.RoundTripper - itr *ghinstallation.Transport +// multiOrgTransport is an http.RoundTripper that routes GitHub API +// requests to the correct GitHub App installation based on the org +// in the request URL path. It caches per-org installation transports +// and lazily discovers new ones on cache miss. +type multiOrgTransport struct { + base http.RoundTripper + appsTr *ghinstallation.AppsTransport + + mu sync.RWMutex + orgTr map[string]*ghinstallation.Transport // lowercase org -> transport } -func (t *gitBasicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { - tok, err := t.itr.Token(req.Context()) +func (t *multiOrgTransport) RoundTrip(req *http.Request) (*http.Response, error) { + org := orgFromPath(req.URL.Path) + if org == "" { + return nil, fmt.Errorf("cannot determine org from request path %q", req.URL.Path) + } + + itr, err := t.transportForOrg(req.Context(), org) + if err != nil { + return nil, fmt.Errorf("getting transport for org %q: %w", org, err) + } + + tok, err := itr.Token(req.Context()) if err != nil { - return nil, fmt.Errorf("getting GitHub App token: %w", err) + return nil, fmt.Errorf("getting GitHub App token for org %q: %w", org, err) } req = req.Clone(req.Context()) req.SetBasicAuth("x-access-token", tok) return t.base.RoundTrip(req) } +// orgFromPath extracts the GitHub org from a URL path like +// "/tailscale/corp.git/info/refs". It returns the lowercased org +// name, or "" if the path doesn't contain one. +func orgFromPath(path string) string { + path = strings.TrimPrefix(path, "/") + slash := strings.IndexByte(path, '/') + if slash <= 0 { + return "" + } + return strings.ToLower(path[:slash]) +} + +// transportForOrg returns a cached ghinstallation.Transport for the +// given org. On cache miss it discovers the installation via the +// GitHub API and caches the result. +func (t *multiOrgTransport) transportForOrg(ctx context.Context, org string) (*ghinstallation.Transport, error) { + t.mu.RLock() + itr, ok := t.orgTr[org] + t.mu.RUnlock() + if ok { + return itr, nil + } + + // Cache miss — discover the installation for this org. + installID, err := t.findInstallationForOrg(ctx, org) + if err != nil { + return nil, err + } + itr = ghinstallation.NewFromAppsTransport(t.appsTr, installID) + t.mu.Lock() + // Only set if we didn't lose the race + if existing, ok := t.orgTr[org]; !ok { + t.orgTr[org] = itr + } else { + itr = existing + } + t.mu.Unlock() + return itr, nil +} + +// findInstallationForOrg calls GET /orgs/{org}/installation using the +// App JWT to discover the installation ID for the given org. +func (t *multiOrgTransport) findInstallationForOrg(ctx context.Context, org string) (int64, error) { + url := fmt.Sprintf("https://api.github.com/orgs/%s/installation", org) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return 0, err + } + resp, err := t.appsTr.RoundTrip(req) + if err != nil { + return 0, fmt.Errorf("GitHub API request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return 0, fmt.Errorf("GitHub App not installed on org %q (HTTP %d)", org, resp.StatusCode) + } + var result struct { + ID int64 `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, fmt.Errorf("decoding installation response: %w", err) + } + if result.ID == 0 { + return 0, fmt.Errorf("no installation ID returned for org %q", org) + } + return result.ID, nil +} + +// discoverAllInstallations calls GET /app/installations to enumerate +// all installations of the GitHub App and pre-populates the org cache. +// It returns the number of installations found. +func (t *multiOrgTransport) discoverAllInstallations(ctx context.Context) (int, error) { + url := "https://api.github.com/app/installations?per_page=100" + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return 0, err + } + resp, err := t.appsTr.RoundTrip(req) + if err != nil { + return 0, fmt.Errorf("listing installations: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return 0, fmt.Errorf("listing installations: HTTP %d", resp.StatusCode) + } + + var installations []struct { + ID int64 `json:"id"` + Account struct { + Login string `json:"login"` + } `json:"account"` + } + if err := json.NewDecoder(resp.Body).Decode(&installations); err != nil { + return 0, fmt.Errorf("decoding installations: %w", err) + } + + var verified bool + for _, inst := range installations { + org := strings.ToLower(inst.Account.Login) + if org == "" { + continue + } + itr := ghinstallation.NewFromAppsTransport(t.appsTr, inst.ID) + + // Verify we can get a token for at least one installation. + if !verified { + if _, err := itr.Token(ctx); err != nil { + return 0, fmt.Errorf("getting initial token for org %q (installation %d): %w", org, inst.ID, err) + } + verified = true + } + + t.mu.Lock() + t.orgTr[org] = itr + t.mu.Unlock() + } + return len(installations), nil +} + func getGitHubAppPrivateKeyFromDisk() (_ []byte, ok bool) { if home, err := os.UserHomeDir(); err == nil { if pem, err := os.ReadFile(filepath.Join(home, "keys", "rogitproxy.pem")); err == nil { diff --git a/github_test.go b/github_test.go new file mode 100644 index 0000000..6a1cf0a --- /dev/null +++ b/github_test.go @@ -0,0 +1,28 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import "testing" + +func TestOrgFromPath(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"/tailscale/corp.git/info/refs", "tailscale"}, + {"/borderzero/border0-cli.git/git-upload-pack", "borderzero"}, + {"/TAILSCALE/Corp.git/info/refs", "tailscale"}, + {"/Org-Name/repo.git/info/refs", "org-name"}, + {"/", ""}, + {"", ""}, + {"/onlyone", ""}, + {"noslash", ""}, + } + for _, tt := range tests { + got := orgFromPath(tt.path) + if got != tt.want { + t.Errorf("orgFromPath(%q) = %q, want %q", tt.path, got, tt.want) + } + } +} diff --git a/rogitproxy.go b/rogitproxy.go index 3a7441a..7acc0ca 100644 --- a/rogitproxy.go +++ b/rogitproxy.go @@ -96,11 +96,18 @@ func main() { if err != nil { log.Fatalf("github app auth: %v", err) } + n, err := tr.discoverAllInstallations(context.Background()) + if err != nil { + log.Fatalf("discoverAllInstallations: %v", err) + } + if n == 0 { + log.Fatalf("no GitHub app installations found") + } proxy.HTTPClient = &http.Client{Transport: tr} if srv != nil { proxy.RequireGrants = true } - log.Printf("github app auth enabled (app %d, installation %d)", *gitHubAppID, *gitHubInstallationID) + log.Printf("github app auth enabled (app %d, %d org installations)", *gitHubAppID, n) } // Start HTTP debug/status server.