diff --git a/internal/updater/doc.go b/internal/updater/doc.go index bbac56d..1452d3d 100644 --- a/internal/updater/doc.go +++ b/internal/updater/doc.go @@ -25,9 +25,11 @@ // differ and the code reflects it: GitHub ENFORCES a UA (a bare request is 403'd, // verified 2026-07-05) so Felis's UA is load-bearing there; PaperMC does NOT // enforce (a bare request got HTTP 200 on 2026-07-04) so its UA is only etiquette. -// Also: felis-api's topology coord "felis/felis" is a PLACEHOLDER slug — the GitHub -// routing/parse logic is verified, but that one component stays dark at runtime (its -// Latest errors, degrading to "latest unknown") until a real repository is configured. +// Also: felis-api's topology coord is now the real repository slug, not a placeholder, +// so that component resolves at runtime like the others — but only with a credential. +// The repo is private, so an unauthenticated poll gets GitHub's 404-for-hidden-repo and +// degrades to "latest unknown"; FELIS_GITHUB_TOKEN is what lights it up. k3s and +// cloudflared are public and need none. // // - ALSO BUILT + UNIT-VERIFIED: the VersionGatherer's extraction core and dispatch. // Three pure extractors turn raw system text into a Version — a `--version` banner diff --git a/internal/updater/github.go b/internal/updater/github.go index ce2cb6e..71543d6 100644 --- a/internal/updater/github.go +++ b/internal/updater/github.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "os" "time" "felis.lolicon.best/internal/updates" @@ -37,14 +38,32 @@ const githubBaseURL = "https://api.github.com" type github struct { baseURL string // e.g. "https://api.github.com" userAgent string // MUST be non-empty — GitHub 403s a UA-less request - hc *http.Client + // token is an optional credential. Empty means unauthenticated, which is the right + // posture for the public repos Felis tracks (k3s, cloudflared) and is what the + // 60-req/h note above is about. It is load-bearing only for felis-api's own repo + // while that repo is private, where its absence does not look like an auth failure: + // GitHub answers 404 — not 401 or 403 — for a private repo the caller cannot see, so + // "no token" is indistinguishable from "no release published yet" by status alone. + // latestStable says both in the error rather than making an operator guess. + // + // It is read from the environment and never compiled in. A constant would be + // committed to the very repository it protects, ship inside every felis binary where + // strings(1) recovers it, reach every node the image is imported onto, and need a + // rebuild and a redeploy to rotate. + token string + hc *http.Client } +// tokenEnv names the environment variable holding the GitHub credential. deploy/bootstrap.sh +// reads the same variable to clone a private repo, so an operator sets one value once. +const tokenEnv = "FELIS_GITHUB_TOKEN" + // newGitHub builds a source pointed at the live GitHub REST API with sane defaults. func newGitHub() github { return github{ baseURL: githubBaseURL, userAgent: defaultUserAgent, + token: os.Getenv(tokenEnv), hc: &http.Client{Timeout: 15 * time.Second}, } } @@ -83,6 +102,9 @@ func (g github) latestStable(ctx context.Context, repo string) (updates.Version, } req.Header.Set("User-Agent", ua) req.Header.Set("Accept", "application/vnd.github+json") + if g.token != "" { + req.Header.Set("Authorization", "Bearer "+g.token) + } resp, err := g.hc.Do(req) if err != nil { @@ -90,6 +112,15 @@ func (g github) latestStable(ctx context.Context, repo string) (updates.Version, } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + // 404 is the ambiguous one: GitHub hides a private repo behind it rather than + // answering 401/403, so an unauthenticated miss and a repo with no stable release + // are the same status. Name both causes, and name the fix for the one an operator + // can act on. + if resp.StatusCode == http.StatusNotFound && g.token == "" { + return updates.Version{}, fmt.Errorf( + "github: %s releases/latest returned HTTP 404 — either it has no published stable release, or it is private and %s is unset", + repo, tokenEnv) + } return updates.Version{}, fmt.Errorf("github: %s releases/latest returned HTTP %d", repo, resp.StatusCode) } diff --git a/internal/updater/github_test.go b/internal/updater/github_test.go index 8c8c05d..519480c 100644 --- a/internal/updater/github_test.go +++ b/internal/updater/github_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -140,3 +141,63 @@ func TestGitHubFailsClosedOnUnparseableTag(t *testing.T) { t.Fatalf("want error on an unparseable tag, got %q", v.String()) } } + +// TestGitHubSendsTokenOnlyWhenSet proves the credential reaches the wire as a Bearer +// header when present, and that an empty token sends NO Authorization header at all — +// the public repos (k3s, cloudflared) must keep working with no credential configured. +func TestGitHubSendsTokenOnlyWhenSet(t *testing.T) { + for _, tc := range []struct { + name, token, wantAuth string + }{ + {"a token is sent as a Bearer credential", "ghp_secret", "Bearer ghp_secret"}, + {"no token sends no Authorization header", "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"tag_name":"v1.2.3","prerelease":false,"draft":false}`)) + })) + defer srv.Close() + + g := newTestGitHub(srv) + g.token = tc.token + if _, err := g.latestStable(context.Background(), "acme/private"); err != nil { + t.Fatalf("latestStable: %v", err) + } + if gotAuth != tc.wantAuth { + t.Errorf("Authorization = %q, want %q", gotAuth, tc.wantAuth) + } + }) + } +} + +// TestGitHubNamesTheTokenOnAnUnauthenticated404 pins the diagnostic that makes a private +// repo debuggable. GitHub hides a repo the caller cannot see behind 404 rather than 401, +// so this status is genuinely ambiguous; the error must name BOTH causes and the env var +// that fixes the actionable one. With a token already set that hint would be wrong, so it +// must not appear. +func TestGitHubNamesTheTokenOnAnUnauthenticated404(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound) + })) + defer srv.Close() + + g := newTestGitHub(srv) + _, err := g.latestStable(context.Background(), "acme/private") + if err == nil { + t.Fatal("want an error on 404") + } + if !strings.Contains(err.Error(), tokenEnv) { + t.Errorf("unauthenticated 404 must name %s so an operator knows the fix; got: %v", tokenEnv, err) + } + + g.token = "ghp_secret" + _, err = g.latestStable(context.Background(), "acme/private") + if err == nil { + t.Fatal("want an error on 404") + } + if strings.Contains(err.Error(), tokenEnv) { + t.Errorf("a 404 WITH a token set must not blame the missing token; got: %v", err) + } +} diff --git a/internal/updater/runner_test.go b/internal/updater/runner_test.go index faa2c70..0ba54db 100644 --- a/internal/updater/runner_test.go +++ b/internal/updater/runner_test.go @@ -141,12 +141,12 @@ func TestRunnerWithRoutingSource(t *testing.T) { })) defer paperSrv.Close() - // GitHub fixtures keyed by repo (felis-api's coord is a placeholder slug). The - // handler also mirrors GitHub's real gate: a UA-less request is refused. + // GitHub fixtures keyed by repo. The handler also mirrors GitHub's real gate: a + // UA-less request is refused. ghBodies := map[string]string{ - "/repos/felis/felis/releases/latest": `{"tag_name":"1.5.0","prerelease":false,"draft":false}`, - "/repos/k3s-io/k3s/releases/latest": k3sLatestFixture, - "/repos/cloudflare/cloudflared/releases/latest": cloudflaredLatestFixture, + "/repos/MliroLirrorsIngenuity/Felis/releases/latest": `{"tag_name":"1.5.0","prerelease":false,"draft":false}`, + "/repos/k3s-io/k3s/releases/latest": k3sLatestFixture, + "/repos/cloudflare/cloudflared/releases/latest": cloudflaredLatestFixture, } ghSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("User-Agent") == "" { diff --git a/internal/updater/topology.go b/internal/updater/topology.go index ff81a5b..4a8cca1 100644 --- a/internal/updater/topology.go +++ b/internal/updater/topology.go @@ -47,10 +47,12 @@ type Spec struct { // pin — there is no policy path by which Topology can propose changing it. func Topology() []Spec { return []Spec{ - // Coord "felis/felis" is a placeholder for the operator's own release repo: the - // GitHub source now consumes it, so the routing/parse path is live, but it will - // not resolve against real GitHub until the operator's actual repo slug is set. - {Name: "felis-api", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "felis/felis"}, + // Felis's own release repo, and the same slug deploy/bootstrap.sh clones from + // (FELIS_REPO_URL). It is PRIVATE today, which is why the github source carries an + // optional token: unauthenticated, this coord answers 404 — the status GitHub uses + // to hide a repo's existence — and felis-api is the one tracked component where + // that happens. k3s and cloudflared are public and need no credential. + {Name: "felis-api", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "MliroLirrorsIngenuity/Felis"}, {Name: "k3s", Policy: updates.PolicyNotify, Manageable: false, Source: sourceGitHub, Coord: "k3s-io/k3s"}, {Name: "cloudflared", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "cloudflare/cloudflared"}, {Name: "velocity", Policy: updates.PolicyNotify, Manageable: false, Source: sourcePaperMC, Coord: "velocity"}, diff --git a/internal/updater/topology_test.go b/internal/updater/topology_test.go index 24b4c49..b45354c 100644 --- a/internal/updater/topology_test.go +++ b/internal/updater/topology_test.go @@ -11,7 +11,7 @@ import ( // allowed to auto-apply. func TestTopologyEncodesPolicies(t *testing.T) { want := map[string]Spec{ - "felis-api": {Name: "felis-api", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "felis/felis"}, + "felis-api": {Name: "felis-api", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "MliroLirrorsIngenuity/Felis"}, "k3s": {Name: "k3s", Policy: updates.PolicyNotify, Manageable: false, Source: sourceGitHub, Coord: "k3s-io/k3s"}, "cloudflared": {Name: "cloudflared", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "cloudflare/cloudflared"}, "velocity": {Name: "velocity", Policy: updates.PolicyNotify, Manageable: false, Source: sourcePaperMC, Coord: "velocity"},