Skip to content

Commit e6fb29b

Browse files
committed
diag: verify credentials and probe reflink/API base for real
doctor's checks reported configuration rather than reality: - reflink capability was inferred from the fs type (btrfs/xfs/zfs), which is wrong for XFS without reflink=1 or ZFS without block cloning. Replace with reflinkProbe(), which runs the same cp --reflink=always the provisioner uses — ground truth. Feeds both doctor and the machine-readable StatusReport.workdir.reflink_capable field. - apiReach hardcoded api.github.com/zen, wrong for GHES/proxy. Derive the API base from the configured URL (githubAPIBase) and treat any HTTP response as reachable (only >=500/transport error warns). - authCheck only proved credentials were present/parseable. Add authVerify: a live read-only call (PAT -> /rate_limit, App -> signed RS256 JWT -> /app/installations/ID) that FAILs on 401/403, WARNs when offline. - workdirCheck no longer MkdirAll's the dir (a diagnostic must not mutate host state); it Stats instead and reports the euid it probed as, since writability is only meaningful when run as the service user. JWT signing is stdlib-only (crypto/rsa + PKCS1/PKCS8 PEM parsing).
1 parent dd6ea22 commit e6fb29b

4 files changed

Lines changed: 357 additions & 22 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,9 +289,10 @@ the host (cross-checked against firecracker processes and tap devices).
289289

290290
`doctor` runs a checklist — `/dev/kvm`, the firecracker/jailer binaries, the
291291
kernel and golden images, the egress interface, `ip_forward`, `nftables`, a
292-
writable and reflink-capable work dir, GitHub auth, and API reachability — and
293-
prints one `PASS`/`WARN`/`FAIL` line each. It exits non-zero when any check
294-
fails, so it can gate a deploy.
292+
writable and reflink-capable work dir, GitHub auth (presence plus a live
293+
read-only credential check), and API reachability (against the derived API base,
294+
so GitHub Enterprise Server works too) — and prints one `PASS`/`WARN`/`FAIL`
295+
line each. It exits non-zero when any check fails, so it can gate a deploy.
295296

296297
### Warm pool (`--min-runners`)
297298

internal/diag/auth.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package diag
2+
3+
import (
4+
"context"
5+
"crypto"
6+
"crypto/rand"
7+
"crypto/rsa"
8+
"crypto/sha256"
9+
"crypto/x509"
10+
"encoding/base64"
11+
"encoding/json"
12+
"encoding/pem"
13+
"errors"
14+
"fmt"
15+
"net/http"
16+
"net/url"
17+
"strings"
18+
"time"
19+
20+
"github.com/solcreek/firerunner/internal/config"
21+
)
22+
23+
// githubAPIBase derives the REST API base URL from the configured org/repo URL,
24+
// so doctor probes the host firerunner actually talks to. github.com maps to
25+
// api.github.com; a GitHub Enterprise Server host serves its API under
26+
// /api/v3 on the same host. An unparseable/empty URL falls back to github.com.
27+
func githubAPIBase(rawURL string) string {
28+
u, err := url.Parse(rawURL)
29+
if err != nil || u.Host == "" {
30+
return "https://api.github.com"
31+
}
32+
switch strings.ToLower(u.Host) {
33+
case "github.com", "www.github.com":
34+
return "https://api.github.com"
35+
}
36+
scheme := u.Scheme
37+
if scheme == "" {
38+
scheme = "https"
39+
}
40+
return scheme + "://" + u.Host + "/api/v3"
41+
}
42+
43+
// authVerify makes a single read-only authenticated request to confirm the
44+
// configured credentials are actually accepted by GitHub — the presence checks
45+
// in authCheck only prove the values are set and parseable. It never mutates
46+
// anything (a PAT hits /rate_limit; a GitHub App hits GET /app/installations/ID
47+
// with a short-lived JWT). It is best-effort: a transport error WARNs (the host
48+
// may simply be offline), an explicit 401/403 FAILs, anything else WARNs.
49+
func authVerify(cfg *config.Config, apiBase string) Check {
50+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
51+
defer cancel()
52+
53+
var req *http.Request
54+
if cfg.Token != "" {
55+
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, apiBase+"/rate_limit", nil)
56+
req.Header.Set("Authorization", "Bearer "+cfg.Token)
57+
} else {
58+
key, err := cfg.ResolvePrivateKey()
59+
if err != nil {
60+
return fail("auth-verify", "GitHub App private key unreadable: %v", err)
61+
}
62+
jwt, err := appJWT(cfg.AppClientID, key, time.Now())
63+
if err != nil {
64+
return fail("auth-verify", "cannot sign GitHub App JWT: %v", err)
65+
}
66+
req, _ = http.NewRequestWithContext(ctx, http.MethodGet,
67+
fmt.Sprintf("%s/app/installations/%d", apiBase, cfg.AppInstallID), nil)
68+
req.Header.Set("Authorization", "Bearer "+jwt)
69+
}
70+
req.Header.Set("Accept", "application/vnd.github+json")
71+
72+
resp, err := http.DefaultClient.Do(req)
73+
if err != nil {
74+
return warn("auth-verify", "could not verify credentials (offline?): %v", err)
75+
}
76+
defer resp.Body.Close()
77+
switch {
78+
case resp.StatusCode == http.StatusOK:
79+
return pass("auth-verify", "credentials accepted by %s", apiBase)
80+
case resp.StatusCode == http.StatusUnauthorized, resp.StatusCode == http.StatusForbidden:
81+
return fail("auth-verify", "credentials rejected (HTTP %d)", resp.StatusCode)
82+
default:
83+
return warn("auth-verify", "unexpected HTTP %d verifying credentials", resp.StatusCode)
84+
}
85+
}
86+
87+
// appJWT builds the short-lived RS256 JWT GitHub requires to authenticate as a
88+
// GitHub App. The issuer is the App's client ID (GitHub also accepts the App
89+
// ID). Kept stdlib-only: PKCS#1/PKCS#8 PEM parsing + rsa.SignPKCS1v15.
90+
func appJWT(issuer, pemKey string, now time.Time) (string, error) {
91+
key, err := parseRSAPrivateKey(pemKey)
92+
if err != nil {
93+
return "", err
94+
}
95+
enc := base64.RawURLEncoding.EncodeToString
96+
header := enc([]byte(`{"alg":"RS256","typ":"JWT"}`))
97+
claims, err := json.Marshal(map[string]any{
98+
"iat": now.Add(-60 * time.Second).Unix(),
99+
"exp": now.Add(9 * time.Minute).Unix(), // GitHub caps App JWTs at 10 min
100+
"iss": issuer,
101+
})
102+
if err != nil {
103+
return "", err
104+
}
105+
signingInput := header + "." + enc(claims)
106+
sum := sha256.Sum256([]byte(signingInput))
107+
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:])
108+
if err != nil {
109+
return "", err
110+
}
111+
return signingInput + "." + enc(sig), nil
112+
}
113+
114+
// parseRSAPrivateKey accepts either a PKCS#1 ("RSA PRIVATE KEY") or PKCS#8
115+
// ("PRIVATE KEY") PEM, as GitHub has issued both formats for App keys.
116+
func parseRSAPrivateKey(pemKey string) (*rsa.PrivateKey, error) {
117+
block, _ := pem.Decode([]byte(pemKey))
118+
if block == nil {
119+
return nil, errors.New("no PEM block found in private key")
120+
}
121+
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
122+
return k, nil
123+
}
124+
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
125+
if err != nil {
126+
return nil, fmt.Errorf("parse private key: %w", err)
127+
}
128+
rk, ok := k.(*rsa.PrivateKey)
129+
if !ok {
130+
return nil, fmt.Errorf("private key is %T, want RSA", k)
131+
}
132+
return rk, nil
133+
}

internal/diag/auth_test.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package diag
2+
3+
import (
4+
"crypto"
5+
"crypto/rand"
6+
"crypto/rsa"
7+
"crypto/sha256"
8+
"crypto/x509"
9+
"encoding/base64"
10+
"encoding/json"
11+
"encoding/pem"
12+
"net/http"
13+
"net/http/httptest"
14+
"strings"
15+
"testing"
16+
"time"
17+
18+
"github.com/solcreek/firerunner/internal/config"
19+
)
20+
21+
func TestGithubAPIBase(t *testing.T) {
22+
cases := map[string]string{
23+
"https://github.com/solcreek": "https://api.github.com",
24+
"https://github.com/org/repo": "https://api.github.com",
25+
"https://www.github.com/org": "https://api.github.com",
26+
"https://ghe.example.com/org": "https://ghe.example.com/api/v3",
27+
"http://ghe.internal/org/repo": "http://ghe.internal/api/v3",
28+
"": "https://api.github.com",
29+
"://bogus": "https://api.github.com",
30+
"ghe.example.com/org": "https://api.github.com", // no scheme => no host
31+
}
32+
for in, want := range cases {
33+
if got := githubAPIBase(in); got != want {
34+
t.Errorf("githubAPIBase(%q) = %q, want %q", in, got, want)
35+
}
36+
}
37+
}
38+
39+
func testRSAKeyPEM(t *testing.T) (string, *rsa.PrivateKey) {
40+
t.Helper()
41+
key, err := rsa.GenerateKey(rand.Reader, 2048)
42+
if err != nil {
43+
t.Fatalf("generate key: %v", err)
44+
}
45+
der := x509.MarshalPKCS1PrivateKey(key)
46+
p := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der})
47+
return string(p), key
48+
}
49+
50+
func TestAppJWT(t *testing.T) {
51+
pemStr, key := testRSAKeyPEM(t)
52+
now := time.Unix(1_700_000_000, 0)
53+
tok, err := appJWT("Iv1.client", pemStr, now)
54+
if err != nil {
55+
t.Fatalf("appJWT: %v", err)
56+
}
57+
parts := strings.Split(tok, ".")
58+
if len(parts) != 3 {
59+
t.Fatalf("token has %d parts, want 3", len(parts))
60+
}
61+
62+
// Signature must verify against the public key.
63+
signing := parts[0] + "." + parts[1]
64+
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
65+
if err != nil {
66+
t.Fatalf("decode sig: %v", err)
67+
}
68+
sum := sha256.Sum256([]byte(signing))
69+
if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, sum[:], sig); err != nil {
70+
t.Fatalf("signature does not verify: %v", err)
71+
}
72+
73+
// Claims must carry issuer and a sane iat/exp window.
74+
rawClaims, err := base64.RawURLEncoding.DecodeString(parts[1])
75+
if err != nil {
76+
t.Fatalf("decode claims: %v", err)
77+
}
78+
var claims struct {
79+
Iat int64 `json:"iat"`
80+
Exp int64 `json:"exp"`
81+
Iss string `json:"iss"`
82+
}
83+
if err := json.Unmarshal(rawClaims, &claims); err != nil {
84+
t.Fatalf("unmarshal claims: %v", err)
85+
}
86+
if claims.Iss != "Iv1.client" {
87+
t.Errorf("iss = %q, want Iv1.client", claims.Iss)
88+
}
89+
if claims.Iat != now.Add(-60*time.Second).Unix() {
90+
t.Errorf("iat = %d", claims.Iat)
91+
}
92+
if claims.Exp <= now.Unix() || claims.Exp > now.Add(10*time.Minute).Unix() {
93+
t.Errorf("exp %d outside GitHub's 10-min window", claims.Exp)
94+
}
95+
}
96+
97+
func TestParseRSAPrivateKey_PKCS8(t *testing.T) {
98+
key, err := rsa.GenerateKey(rand.Reader, 2048)
99+
if err != nil {
100+
t.Fatal(err)
101+
}
102+
der, err := x509.MarshalPKCS8PrivateKey(key)
103+
if err != nil {
104+
t.Fatal(err)
105+
}
106+
p := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
107+
if _, err := parseRSAPrivateKey(string(p)); err != nil {
108+
t.Fatalf("PKCS8 parse: %v", err)
109+
}
110+
if _, err := parseRSAPrivateKey("not a pem"); err == nil {
111+
t.Error("want error for non-PEM input")
112+
}
113+
}
114+
115+
func TestAuthVerify_PAT(t *testing.T) {
116+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117+
if r.URL.Path != "/rate_limit" {
118+
t.Errorf("PAT path = %q, want /rate_limit", r.URL.Path)
119+
}
120+
if r.Header.Get("Authorization") != "Bearer ghp_valid" {
121+
w.WriteHeader(http.StatusUnauthorized)
122+
return
123+
}
124+
w.WriteHeader(http.StatusOK)
125+
}))
126+
defer srv.Close()
127+
128+
if c := authVerify(&config.Config{Token: "ghp_valid"}, srv.URL); c.Level != levelPass {
129+
t.Errorf("valid PAT: got %s %s", c.Level, c.Detail)
130+
}
131+
if c := authVerify(&config.Config{Token: "ghp_wrong"}, srv.URL); c.Level != levelFail {
132+
t.Errorf("wrong PAT: want FAIL, got %s", c.Level)
133+
}
134+
// Unreachable endpoint => WARN, not FAIL.
135+
if c := authVerify(&config.Config{Token: "ghp_valid"}, "http://127.0.0.1:1"); c.Level != levelWarn {
136+
t.Errorf("offline: want WARN, got %s %s", c.Level, c.Detail)
137+
}
138+
}
139+
140+
func TestAuthVerify_App(t *testing.T) {
141+
pemStr, _ := testRSAKeyPEM(t)
142+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
143+
if r.URL.Path != "/app/installations/123" {
144+
t.Errorf("App path = %q", r.URL.Path)
145+
}
146+
auth := r.Header.Get("Authorization")
147+
if !strings.HasPrefix(auth, "Bearer ") || strings.Count(auth, ".") != 2 {
148+
w.WriteHeader(http.StatusUnauthorized)
149+
return
150+
}
151+
w.WriteHeader(http.StatusOK)
152+
}))
153+
defer srv.Close()
154+
155+
cfg := &config.Config{AppClientID: "Iv1.client", AppInstallID: 123, AppPrivateKey: pemStr}
156+
if c := authVerify(cfg, srv.URL); c.Level != levelPass {
157+
t.Errorf("valid App: got %s %s", c.Level, c.Detail)
158+
}
159+
160+
bad := &config.Config{AppClientID: "Iv1.client", AppInstallID: 123, AppPrivateKey: "PRIVATE KEY garbage"}
161+
if c := authVerify(bad, srv.URL); c.Level != levelFail {
162+
t.Errorf("bad key: want FAIL, got %s", c.Level)
163+
}
164+
}
165+
166+
func TestReflinkProbe_BogusDir(t *testing.T) {
167+
if reflinkProbe("/nonexistent/firerunner-does-not-exist") {
168+
t.Error("reflinkProbe on missing dir should be false")
169+
}
170+
}

0 commit comments

Comments
 (0)