Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions cmd/doctor/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,15 @@ func getHost(f *cmdutil.Factory) (string, error) {

// doGet performs a GET request against url with the given context, returning
// the response. The caller is responsible for closing resp.Body.
func doGet(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
//
// host is used to look up per-host headers (hosts.yml + Cloudflare Access env)
// so doctor can reach environments behind CF Access.
func doGet(ctx context.Context, client *http.Client, host, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
khhttp.ApplyHostHeaders(req, host)
return client.Do(req)
}

Expand All @@ -168,7 +172,7 @@ func checkAuth(ctx context.Context, f *cmdutil.Factory) CheckResult {
}

url := khhttp.BuildBaseURL(host) + "/api/auth/get-session"
resp, err := doGet(ctx, client, url)
resp, err := doGet(ctx, client, host, url)
if err != nil {
if isContextTimeout(err) {
return CheckResult{Status: "warn", Message: "auth check timed out"}
Expand Down Expand Up @@ -202,7 +206,7 @@ func checkAPI(ctx context.Context, f *cmdutil.Factory) CheckResult {

url := khhttp.BuildBaseURL(host) + "/api/health"
start := time.Now()
resp, err := doGet(ctx, client, url)
resp, err := doGet(ctx, client, host, url)
latency := time.Since(start)

if err != nil {
Expand Down Expand Up @@ -234,7 +238,7 @@ func checkWallet(ctx context.Context, f *cmdutil.Factory) CheckResult {
}

url := khhttp.BuildBaseURL(host) + "/api/user/wallet/balances"
resp, err := doGet(ctx, client, url)
resp, err := doGet(ctx, client, host, url)
if err != nil {
if isContextTimeout(err) {
return CheckResult{Status: "warn", Message: "wallet check timed out"}
Expand Down Expand Up @@ -277,7 +281,7 @@ func checkSpendCap(ctx context.Context, f *cmdutil.Factory) CheckResult {
}

url := khhttp.BuildBaseURL(host) + "/api/billing/subscription"
resp, err := doGet(ctx, client, url)
resp, err := doGet(ctx, client, host, url)
if err != nil {
if isContextTimeout(err) {
return CheckResult{Status: "warn", Message: "spend cap check timed out"}
Expand Down Expand Up @@ -329,7 +333,7 @@ func checkChains(ctx context.Context, f *cmdutil.Factory) CheckResult {
}

url := khhttp.BuildBaseURL(host) + "/api/chains"
resp, err := doGet(ctx, client, url)
resp, err := doGet(ctx, client, host, url)
if err != nil {
if isContextTimeout(err) {
return CheckResult{Status: "fail", Message: "could not reach chain service (timeout after 5s)"}
Expand Down
2 changes: 1 addition & 1 deletion cmd/kh/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func main() {
return khhttp.NewClient(khhttp.ClientOptions{
Host: activeHost,
Token: resolved.Token,
Headers: entry.Headers,
Headers: khhttp.MergeCloudflareAccessEnv(entry.Headers),
OrgOverride: resolveOrgFlag(),
IOStreams: ios,
AppVersion: version.Version,
Expand Down
20 changes: 20 additions & 0 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,26 @@ Run `kh auth login` to authenticate via browser OAuth. For headless environments

Run `kh auth status` to see which method is active and whether your token is valid.

## Gated Environments (Cloudflare Access)

Hosts behind Cloudflare Access (PR previews, staging) require additional credentials on every request. The CLI sends them as headers from two sources:

1. **Environment variables** (precedence: env > hosts.yml):
- `CF_ACCESS_CLIENT_ID` + `CF_ACCESS_CLIENT_SECRET` -- service-token pair, sent as `CF-Access-Client-Id` / `CF-Access-Client-Secret`. Both must be set; partial values are ignored.
- `CF_AUTHORIZATION` -- the `cf_authorization` JWT minted by `cloudflared access login`, sent as `Cookie: CF_Authorization=<value>`.
2. **Per-host `headers:` map in `hosts.yml`** -- arbitrary headers attached to every request to that host:

```yaml
hosts:
app-pr-1234.keeperhub.com:
token: kh_prte_...
headers:
CF-Access-Client-Id: <id>
CF-Access-Client-Secret: <secret>
```

Use env vars in CI; use `hosts.yml` for stable per-environment config that follows your machine.

## Output Formats

By default, most commands render a human-readable table. Use these flags for machine-readable output:
Expand Down
3 changes: 3 additions & 0 deletions internal/auth/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ func fetchSessionInfo(host, token string) (TokenInfo, error) {
return TokenInfo{}, fmt.Errorf("creating session request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
khhttp.ApplyHostHeaders(req, host)

resp, err := client.Do(req)
if err != nil {
Expand Down Expand Up @@ -142,6 +143,7 @@ func fetchAPIKeyInfo(host, token string) (TokenInfo, error) {
return TokenInfo{}, fmt.Errorf("creating validation request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
khhttp.ApplyHostHeaders(req, host)

resp, err := client.Do(req)
if err != nil {
Expand Down Expand Up @@ -175,6 +177,7 @@ func fetchOrgDetails(client *http.Client, host, token, orgID string) (string, st
return "", ""
}
req.Header.Set("Authorization", "Bearer "+token)
khhttp.ApplyHostHeaders(req, host)

resp, err := client.Do(req)
if err != nil || resp.StatusCode != http.StatusOK {
Expand Down
72 changes: 72 additions & 0 deletions internal/http/cfaccess.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package khhttp

import (
"net/http"
"os"

"github.com/keeperhub/cli/internal/config"
)

// Cloudflare Access env vars. CF_ACCESS_CLIENT_ID + CF_ACCESS_CLIENT_SECRET
// is the service-token pair used by CI; CF_AUTHORIZATION carries the
// cf_authorization JWT minted by `cloudflared access login` for interactive
// devs. Both are sent when set; CF Access accepts either.
const (
envCFAccessClientID = "CF_ACCESS_CLIENT_ID"
envCFAccessClientSecret = "CF_ACCESS_CLIENT_SECRET"
envCFAuthorization = "CF_AUTHORIZATION"
)

// MergeCloudflareAccessEnv returns base with Cloudflare Access headers added
// from the environment. Env values take precedence over base entries with the
// same key (matching the KH_API_KEY > hosts.yml precedence used elsewhere).
//
// Service-token headers are only added when both ID and secret are set, to
// avoid sending half a credential pair.
func MergeCloudflareAccessEnv(base map[string]string) map[string]string {
id := os.Getenv(envCFAccessClientID)
secret := os.Getenv(envCFAccessClientSecret)
cookie := os.Getenv(envCFAuthorization)

if id == "" && secret == "" && cookie == "" {
return base
}

out := make(map[string]string, len(base)+3)
for k, v := range base {
out[k] = v
}
if id != "" && secret != "" {
out["CF-Access-Client-Id"] = id
out["CF-Access-Client-Secret"] = secret
}
if cookie != "" {
pair := "CF_Authorization=" + cookie
if existing, ok := out["Cookie"]; ok && existing != "" {
out["Cookie"] = existing + "; " + pair
} else {
out["Cookie"] = pair
}
}
return out
}

// HeadersForHost returns the set of headers to apply to a request targeting
// host: per-host entries from hosts.yml with Cloudflare Access env vars merged
// on top. Used by code paths that build their own *http.Client and bypass the
// shared khhttp.Client (auth token validation, doctor checks).
func HeadersForHost(host string) map[string]string {
hosts, err := config.ReadHosts()
if err != nil {
return MergeCloudflareAccessEnv(nil)
}
entry, _ := hosts.HostEntry(host)
return MergeCloudflareAccessEnv(entry.Headers)
}

// ApplyHostHeaders sets per-host headers (hosts.yml + CF env) on req.
func ApplyHostHeaders(req *http.Request, host string) {
for k, v := range HeadersForHost(host) {
req.Header.Set(k, v)
}
}
105 changes: 105 additions & 0 deletions internal/http/cfaccess_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package khhttp_test

import (
"testing"

khhttp "github.com/keeperhub/cli/internal/http"
"github.com/stretchr/testify/assert"
)

func TestMergeCloudflareAccessEnv_NoEnv(t *testing.T) {
t.Setenv("CF_ACCESS_CLIENT_ID", "")
t.Setenv("CF_ACCESS_CLIENT_SECRET", "")
t.Setenv("CF_AUTHORIZATION", "")

base := map[string]string{"X-Existing": "v"}
out := khhttp.MergeCloudflareAccessEnv(base)

assert.Equal(t, base, out, "with no env vars, base should pass through unchanged")
}

func TestMergeCloudflareAccessEnv_ServiceToken(t *testing.T) {
t.Setenv("CF_ACCESS_CLIENT_ID", "id-123")
t.Setenv("CF_ACCESS_CLIENT_SECRET", "secret-abc")
t.Setenv("CF_AUTHORIZATION", "")

out := khhttp.MergeCloudflareAccessEnv(nil)

assert.Equal(t, "id-123", out["CF-Access-Client-Id"])
assert.Equal(t, "secret-abc", out["CF-Access-Client-Secret"])
assert.NotContains(t, out, "Cookie")
}

func TestMergeCloudflareAccessEnv_PartialServiceTokenIgnored(t *testing.T) {
// Only ID set, no secret -> nothing added (would 403 anyway and is misleading).
t.Setenv("CF_ACCESS_CLIENT_ID", "id-only")
t.Setenv("CF_ACCESS_CLIENT_SECRET", "")
t.Setenv("CF_AUTHORIZATION", "")

out := khhttp.MergeCloudflareAccessEnv(nil)

assert.NotContains(t, out, "CF-Access-Client-Id")
assert.NotContains(t, out, "CF-Access-Client-Secret")
}

func TestMergeCloudflareAccessEnv_Cookie(t *testing.T) {
t.Setenv("CF_ACCESS_CLIENT_ID", "")
t.Setenv("CF_ACCESS_CLIENT_SECRET", "")
t.Setenv("CF_AUTHORIZATION", "jwt-token")

out := khhttp.MergeCloudflareAccessEnv(nil)

assert.Equal(t, "CF_Authorization=jwt-token", out["Cookie"])
}

func TestMergeCloudflareAccessEnv_EnvWinsOverBase(t *testing.T) {
t.Setenv("CF_ACCESS_CLIENT_ID", "env-id")
t.Setenv("CF_ACCESS_CLIENT_SECRET", "env-secret")
t.Setenv("CF_AUTHORIZATION", "")

base := map[string]string{
"CF-Access-Client-Id": "yaml-id",
"CF-Access-Client-Secret": "yaml-secret",
"X-Other": "preserved",
}
out := khhttp.MergeCloudflareAccessEnv(base)

assert.Equal(t, "env-id", out["CF-Access-Client-Id"])
assert.Equal(t, "env-secret", out["CF-Access-Client-Secret"])
assert.Equal(t, "preserved", out["X-Other"])
}

func TestMergeCloudflareAccessEnv_CookieAppendsToExisting(t *testing.T) {
t.Setenv("CF_ACCESS_CLIENT_ID", "")
t.Setenv("CF_ACCESS_CLIENT_SECRET", "")
t.Setenv("CF_AUTHORIZATION", "jwt-token")

base := map[string]string{"Cookie": "session=abc"}
out := khhttp.MergeCloudflareAccessEnv(base)

assert.Equal(t, "session=abc; CF_Authorization=jwt-token", out["Cookie"],
"existing Cookie value should be preserved and CF_Authorization appended")
}

func TestMergeCloudflareAccessEnv_ServiceTokenAndCookieCombined(t *testing.T) {
t.Setenv("CF_ACCESS_CLIENT_ID", "id-1")
t.Setenv("CF_ACCESS_CLIENT_SECRET", "secret-1")
t.Setenv("CF_AUTHORIZATION", "jwt-1")

out := khhttp.MergeCloudflareAccessEnv(nil)

assert.Equal(t, "id-1", out["CF-Access-Client-Id"])
assert.Equal(t, "secret-1", out["CF-Access-Client-Secret"])
assert.Equal(t, "CF_Authorization=jwt-1", out["Cookie"])
}

func TestMergeCloudflareAccessEnv_DoesNotMutateBase(t *testing.T) {
t.Setenv("CF_ACCESS_CLIENT_ID", "id")
t.Setenv("CF_ACCESS_CLIENT_SECRET", "secret")
t.Setenv("CF_AUTHORIZATION", "")

base := map[string]string{"K": "v"}
_ = khhttp.MergeCloudflareAccessEnv(base)

assert.Equal(t, map[string]string{"K": "v"}, base, "base map should not be mutated")
}
Loading