-
Notifications
You must be signed in to change notification settings - Fork 5
feat: point gitlab-cli at a self-hosted host via GITLAB_HOST #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -8,3 +8,28 @@ requests from the command line. Opt-in (disabled by default). | |||||||||||||||||||||||||||||||
| ## Installation | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| Downloads the latest `.deb` release from GitLab's release API. Requires root. | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| ## Auth | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| `GITLAB_TOKEN`/`GITLAB_ACCESS_TOKEN`, `OAUTH_TOKEN` and | ||||||||||||||||||||||||||||||||
| `JOB_TOKEN`/`CI_JOB_TOKEN` are resolved from the host environment, the layered | ||||||||||||||||||||||||||||||||
| secrets files, or the persisted env store, and injected as env vars. `spec.yaml` | ||||||||||||||||||||||||||||||||
| maps each to the header the gateway injects. | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| ## Self-hosted instances | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| Set `GITLAB_HOST` to point `glab` at a self-hosted instance: | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| ```bash | ||||||||||||||||||||||||||||||||
| GITLAB_HOST=gitlab.example.com enclave --features +gitlab-cli | ||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| That single env var selects the instance for `glab`, retargets token injection, | ||||||||||||||||||||||||||||||||
| and joins the network allowlist — no spec edit or `allow_domains` entry. See | ||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. True at session start, but enclave/internal/policy/effective_resolver.go Lines 134 to 143 in 5e80820
enclave/internal/app/network_cmd.go Lines 291 to 295 in 5e80820
network status reports drift and an apply cuts the self-hosted host out of a running session. The gap already exists for gitlab.com, but a follow-up issue would be good since the override makes it the only host the token can reach.
|
||||||||||||||||||||||||||||||||
| `serviceAuth.hostsFromCredential` in `docs/extensions/README.md` for the | ||||||||||||||||||||||||||||||||
| accepted value forms and why the host replaces the `gitlab.com` defaults instead | ||||||||||||||||||||||||||||||||
| of adding to them. | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| To set it persistently, put it in a secrets file rather than the shell — global | ||||||||||||||||||||||||||||||||
| in `~/.local/state/enclave/secrets/global.env`, or per project under | ||||||||||||||||||||||||||||||||
| `~/.local/state/enclave/projects/<hash>/`. | ||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrong path. Per-project secrets live at Lines 136 to 138 in 5e80820
claude.env), not by the feature, which is worth spelling out since this is a mixin.
|
||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,8 +18,11 @@ credentials: | |
| gitlab-token: { env: [GITLAB_TOKEN, GITLAB_ACCESS_TOKEN] } | ||
| gitlab-oauth-token: { env: [OAUTH_TOKEN] } | ||
| gitlab-job-token: { env: [JOB_TOKEN, CI_JOB_TOKEN] } | ||
| # Not a credential: glab reads GITLAB_HOST to pick the instance, and | ||
| # hostsFromCredential below retargets token injection at it. | ||
| gitlab-host: { env: [GITLAB_HOST], apiKey: false } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because this is a declared credential, the resolved |
||
| network: | ||
| serviceAuth: | ||
| gitlab-token: { headerName: private-token, hosts: [gitlab.com, "*.gitlab.com"] } | ||
| gitlab-oauth-token: { headerName: authorization, valueFormat: "Bearer %s", hosts: [gitlab.com, "*.gitlab.com"] } | ||
| gitlab-job-token: { headerName: job-token, hosts: [gitlab.com, "*.gitlab.com"] } | ||
| gitlab-token: { headerName: private-token, hosts: [gitlab.com, "*.gitlab.com"], hostsFromCredential: gitlab-host } | ||
| gitlab-oauth-token: { headerName: authorization, valueFormat: "Bearer %s", hosts: [gitlab.com, "*.gitlab.com"], hostsFromCredential: gitlab-host } | ||
| gitlab-job-token: { headerName: job-token, hosts: [gitlab.com, "*.gitlab.com"], hostsFromCredential: gitlab-host } | ||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -11,7 +11,10 @@ import ( | |||
| "fmt" | ||||
| "os" | ||||
| "sort" | ||||
| "strings" | ||||
|
|
||||
| "enclave/internal/domainpattern" | ||||
| "enclave/internal/logx" | ||||
| "enclave/internal/model" | ||||
| "enclave/internal/secretfile" | ||||
| ) | ||||
|
|
@@ -163,6 +166,79 @@ func resolveEnvAliasValue(secret activeSecret, layeredSecrets map[string]string, | |||
| return chosen.value, chosen.source, true, nil | ||||
| } | ||||
|
|
||||
| // resolveReleaseHostOverrides resolves the credentials named by each release | ||||
| // rule's HostsFromSecret and returns secret-id -> replacement hosts. A service | ||||
| // whose host credential is unset or unusable keeps its declared hosts, so the | ||||
| // default (e.g. gitlab.com) still applies. | ||||
| // | ||||
| // The replacement is deliberately not additive: the referenced credential names | ||||
| // the one instance the token belongs to, and injecting an instance-specific | ||||
| // token into the default hosts as well would expose it to a service that cannot | ||||
| // accept it. | ||||
| func resolveReleaseHostOverrides(secrets []activeSecret, hostHome string, layeredSecrets map[string]string, persistedEnv map[string]string) map[string][]string { | ||||
| // Several services commonly share one host credential (gitlab's three | ||||
| // tokens), so group by the reference and resolve each credential once. | ||||
| byID := make(map[string]activeSecret, len(secrets)) | ||||
| referencedBy := map[string][]string{} | ||||
| for _, secret := range secrets { | ||||
| byID[secret.ID] = secret | ||||
| if secret.ReleaseHTTP != nil && secret.ReleaseHTTP.HostsFromSecret != "" { | ||||
| ref := secret.ReleaseHTTP.HostsFromSecret | ||||
| referencedBy[ref] = append(referencedBy[ref], secret.ID) | ||||
| } | ||||
| } | ||||
|
|
||||
| overrides := map[string][]string{} | ||||
| for ref, secretIDs := range referencedBy { | ||||
| source, ok := byID[ref] | ||||
| if !ok { | ||||
| continue | ||||
| } | ||||
| value, _, found, err := resolveActiveSecretValue(source, hostHome, layeredSecrets, persistedEnv) | ||||
| if err != nil { | ||||
| logx.Warnf("Cannot resolve host credential %s (%v); keeping declared hosts.", ref, err) | ||||
| continue | ||||
| } | ||||
| if !found { | ||||
| continue | ||||
| } | ||||
| host := normalizeReleaseHost(ref, value) | ||||
| if host == "" { | ||||
| continue | ||||
| } | ||||
| for _, id := range secretIDs { | ||||
| overrides[id] = []string{host} | ||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A successful override is completely silent, and the injection debug log redacts the value, so there is no way to see which host was picked. An info line here would help, similar to how ad-hoc allowlisted domains are reported:
|
||||
| } | ||||
| } | ||||
| return overrides | ||||
| } | ||||
|
|
||||
| // normalizeReleaseHost turns a credential value into an allowlist-comparable | ||||
| // host, tolerating the URL forms CLIs accept (glab reads GITLAB_HOST as either | ||||
| // a bare host or a full URL). An unusable value warns and is dropped rather | ||||
| // than failing the session, since the declared hosts remain valid. | ||||
| func normalizeReleaseHost(secretID string, value string) string { | ||||
| trimmed := value | ||||
| if index := strings.Index(trimmed, "://"); index >= 0 { | ||||
| trimmed = trimmed[index+len("://"):] | ||||
| } | ||||
| if index := strings.IndexAny(trimmed, "/?#"); index >= 0 { | ||||
| trimmed = trimmed[:index] | ||||
| } | ||||
| // NormalizeHost strips the port and IPv6 brackets but does not validate the | ||||
| // labels, so the pattern validator runs after it to reject junk that would | ||||
| // otherwise become a bogus allowlist entry. | ||||
| host, err := domainpattern.NormalizeHost(trimmed) | ||||
| if err == nil { | ||||
| host, err = domainpattern.Normalize(host) | ||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||
| } | ||||
| if err != nil { | ||||
| logx.Warnf("Secret %s: value %q is not a usable host (%v); keeping declared hosts.", secretID, value, err) | ||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This prints the credential value verbatim at warn level. If enclave/internal/runtime/auth_manager.go Line 208 in 5e80820
|
||||
| return "" | ||||
| } | ||||
| return host | ||||
| } | ||||
|
|
||||
| // resolveFileSecretValue reads the secret's file source, if any. A missing file | ||||
| // (or an empty resolved value) reports found=false so the caller can fall back | ||||
| // to the env aliases; a malformed parser or file content fails loudly. | ||||
|
|
||||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -168,6 +168,9 @@ func (m authManager) injectDeclaredSecrets(hooks auth.Hooks, authCtx auth.Contex | |||||||||||
| for _, secret := range suppressedActiveSecrets { | ||||||||||||
| logx.Debugf("Suppressed declared API key secret %s due to %s", secret.ID, suppressionReason) | ||||||||||||
| } | ||||||||||||
| // Must precede shouldUseSecretReleases: that call resolves and caches the | ||||||||||||
| // effective policy, which unions the release hosts into the allow set. | ||||||||||||
| m.releaseHostOverrides = resolveReleaseHostOverrides(eligibleSecrets, m.host.Home, layeredSecrets, persistedEnv) | ||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The invariant is enforced only by this comment, and enclave/internal/runtime/runtime.go Lines 1507 to 1511 in 5e80820
prepareMounts (which runs before prepareVolumes) would silently cache an allow set without the override while the token still targets it. Either reset policyResolved here when overrides are non-empty, or fail loudly if the policy is already resolved.
|
||||||||||||
| secretReleaseEnabled := m.shouldUseSecretReleases(eligibleSecrets) | ||||||||||||
| for _, secret := range eligibleSecrets { | ||||||||||||
| secretValue, secretSource, found, err := resolveActiveSecretValue(secret, m.host.Home, layeredSecrets, persistedEnv) | ||||||||||||
|
|
@@ -190,7 +193,7 @@ func (m authManager) injectDeclaredSecrets(hooks auth.Hooks, authCtx auth.Contex | |||||||||||
| SecretID: secret.ID, | ||||||||||||
| Placeholder: placeholder, | ||||||||||||
| Value: secretValue, | ||||||||||||
| Hosts: append([]string{}, secret.ReleaseHTTP.Hosts...), | ||||||||||||
| Hosts: m.releaseHostsFor(secret), | ||||||||||||
| Header: secret.ReleaseHTTP.Header, | ||||||||||||
| Format: secret.ReleaseHTTP.Format, | ||||||||||||
| }) | ||||||||||||
|
|
@@ -414,6 +417,15 @@ func (m authManager) apiKeySecretSuppressionReason() string { | |||||||||||
| return "" | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| // releaseHostsFor returns the hosts a secret's release rule applies to, after | ||||||||||||
| // any serviceAuth.hostsFromCredential replacement. | ||||||||||||
| func (m authManager) releaseHostsFor(secret activeSecret) []string { | ||||||||||||
| if hosts, ok := m.releaseHostOverrides[secret.ID]; ok { | ||||||||||||
| return append([]string{}, hosts...) | ||||||||||||
| } | ||||||||||||
| return append([]string{}, secret.ReleaseHTTP.Hosts...) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| func (m authManager) shouldUseSecretReleases(activeSecrets []activeSecret) bool { | ||||||||||||
| hasHTTPRelease := false | ||||||||||||
| for _, secret := range activeSecrets { | ||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It replaces the union of
hostsand theserviceDomains-derived hosts, not just the declaredhosts: both go into the same list inbuildSecrets(enclave/internal/config/spec_map.go
Lines 115 to 124 in 5e80820
github-clithat express hosts viaserviceDomains.