Skip to content

feat: dockerised toolbox with the platform CLIs preauthenticated - #1

Merged
venkatamutyala merged 7 commits into
mainfrom
feat/dockerised-toolbox
Aug 30, 2026
Merged

venkatamutyala merged 7 commits into
mainfrom
feat/dockerised-toolbox

Conversation

@venkatamutyala

@venkatamutyala venkatamutyala commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Developers can't use the platform CLIs today. Everything on a GlueOps cluster sits behind oauth2-proxy, which expects a browser session cookie; a CLI has none, so every request is answered with a login redirect — and they have no kubectl to work around it.

argocd login … --sso   ->  rpc error: unexpected EOF        (got the 302 HTML, not gRPC)
bao login -method=oidc ->  invalid character 'R'            (parsing "Redirecting…")

This ships the CLIs in a container that deals with the edge, so they behave normally:

docker run -it --rm \
  -e TOOLBOX_CAPTAIN_DOMAIN=<captain-domain> \
  -v glueops-toolbox:/home/toolbox/.config/glueops \
  ghcr.io/glueops/toolbox:latest

argocd app list
bao kv get secret/my-app

One browser approval. No argocd login, no bao login, no flags, no kubectl.

How

Device flow, not loopback. toolbox-login uses the OIDC device flow against Dex — no loopback listener and no redirect URI, so the browser can live on the host. A localhost:8085 callback could not work from a container. Dex returns a refresh token too, so the browser step happens once, not daily.

One token, three verifiers. The Dex id_token is checked independently by oauth2-proxy (oidc_extra_audiences), ArgoCD (allowedAudiences) and OpenBao (jwt roles' bound_audiences). None of them coordinate — they each verify the same signature against Dex's JWKS.

Each side wants it in a different header, which is most of what made this fiddly:

header read by
ARGOCD_AUTH_TOKEN Token: ArgoCD
-H "Authorization: …" Authorization: Bearer oauth2-proxy
bao X-Vault-Token OpenBao

The argocd wrapper sends the first two. For bao there is no workable wrapper — -header must sit after the subcommand and before any positional, and bao kv list secret is indistinguishable from a subcommand plus a path:

bao kv get -header="…" secret/foo     ✓
bao kv get secret/foo -header="…"     ✗  flags must precede positional arguments
bao -header="…" kv get secret/foo     ✗  no global flag position

So a loopback proxy adds the header and forwards upstream, with BAO_ADDR pointed at it. bao needs no flags and scripts work unmodified. It binds 127.0.0.1 only — it attaches the caller's credential to whatever it forwards.

toolbox-login also exchanges the Dex token for an OpenBao token (posting to the login endpoint directly, since the OpenBao CLI registers no jwt method), so both CLIs are usable after one login.

Restricting access

TOOLBOX_BAO_ROLES=reader pins the session to read-only. Verified: bao kv put403 permission denied, nothing written.

Platforms

linux/amd64 and linux/arm64, so Apple Silicon is native. TARGETARCH has no default on purpose — a default would silently produce an arm64 image full of amd64 binaries when built natively on a Mac. CI builds both and smoke-tests the native one.

Releases

release-please, matching the other GlueOps repos. The manifest starts at 0.0.0 with bump-patch-for-minor-pre-major, so the first release is v0.0.1 rather than v0.1.0, without depending on how this PR is merged.

Testing

Run end to end against a live cluster, twice, as a developer would — fresh image, fresh container, empty volume, real browser approval:

toolbox-login        Authenticated.  OpenBao: logged in as editor.
argocd app list      30 applications
argocd cluster list  in-cluster, 1.34, Successful
bao status           Sealed false, HA Mode active
bao token lookup     jwt-<user>  policies ['default','editor']
bao kv list secret/  3 secrets

TOOLBOX_BAO_ROLES=reader   ->  Already authenticated (no second prompt)
                               policies ['default','reader']
bao kv put                 ->  403 permission denied

Four bugs were found by actually running it, not by review:

  • ARGOCD_AUTH_TOKEN alone never reaches the edge — ArgoCD sends it in Token:, which oauth2-proxy doesn't read.
  • -H alone gets past the edge but leaves Token: empty, so ArgoCD answers "no session information". Both headers are needed.
  • A VOLUME directive made the token cache root-owned and gave a fresh anonymous volume per docker run, so the cache could never persist.
  • docker exec bypasses the ENTRYPOINT, leaving BAO_ADDR unset — bao then fell back to its own default, which is the same port on https, failing confusingly.

Cluster prerequisites

A public toolbox Dex client, that audience in oauth2-proxy's oidc_extra_audiences and ArgoCD's allowedAudiences, and jwt-type OpenBao roles bound to it — GlueOps/platform-helm-chart-platform#1485 and GlueOps/terraform-module-kubernetes-hashicorp-vault-configuration#68.

ghcr.io/glueops/toolbox does not exist until this merges; PR builds don't push.

Everything on a GlueOps cluster sits behind oauth2-proxy, which expects a browser
session cookie. CLIs do not have one, so out of the box every argocd and bao
request is answered with a login redirect - and developers have no kubectl to
work around it. Ship the CLIs in a container that handles the edge, so they can
be used normally.

Authentication uses the OIDC device flow against Dex. That is what makes this
work from a container: there is no loopback listener and no redirect URI, so the
browser can live on the host - a localhost:8085 callback could not. Dex issues a
refresh token alongside, so the browser step happens once rather than daily.

ArgoCD accepts the same token directly (the platform sets the toolbox audience in
allowedAudiences), so one token satisfies both the edge and ArgoCD. The wrapper
sets ARGOCD_AUTH_TOKEN per call, so a long-lived shell never goes stale.

OpenBao cannot work that way: its own credential travels in X-Vault-Token while
the edge wants an Authorization bearer, and the -header flag that would supply it
must sit after the subcommand and before any positional argument -

    bao kv get -header="..." secret/foo     ok
    bao kv get secret/foo -header="..."     rejected
    bao -header="..." kv get secret/foo     rejected

- and since `bao kv list secret` is indistinguishable from a subcommand plus a
path, no wrapper can place it correctly in general. So a small loopback proxy
adds the header and forwards upstream, with BAO_ADDR pointed at it. bao then
needs no flags and scripts work unmodified. It binds 127.0.0.1 only, since it
attaches the caller's credential to whatever it forwards.

Built for linux/amd64 and linux/arm64 so Apple Silicon is native. TARGETARCH is
left without a default deliberately: a default would silently produce an arm64
image full of amd64 binaries when built natively on a Mac.

No VOLUME directive, and the cache directory is created owned by the runtime
user. A VOLUME would create a fresh anonymous volume per `docker run`, so the
token cache would never survive a restart, and an unowned mount point makes the
first cache write fail outright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM
ARGOCD_AUTH_TOKEN does not work through oauth2-proxy. The CLI sends that
credential in a `Token:` header, and oauth2-proxy only reads `Authorization` - so
the edge saw no bearer at all, bounced the request to a login page, and the CLI
reported `rpc error: code = Unknown desc = unexpected EOF`, which says nothing
about what went wrong.

Confirmed by dumping what the client sends:

    ARGOCD_AUTH_TOKEN=x      ->  Token: x
    -H "Authorization: ..."  ->  Authorization: Bearer x

-H is a global flag, so it can precede the subcommand and the wrapper stays
trivial. Verified against the live cluster: oauth2-proxy now logs a bearer
verification attempt for argocd-client requests, where previously it logged none.

Also stop reporting every upstream 3xx as an edge rejection. OpenBao's own 403 was
being rewritten into a login redirect by the platform's errors-redirect plugin,
and this message attributed that to a bad token - which sent the diagnosis in
entirely the wrong direction. Only a redirect to the oauth2 login endpoint is an
auth failure now; anything else is reported as what it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM
@github-actions github-actions Bot added the patch label Aug 30, 2026
venkatamutyala and others added 5 commits August 30, 2026 15:11
Two follow-on failures from the previous fix, both found against the live cluster.

argocd needs the token in TWO headers, because each side reads only its own:

    ARGOCD_AUTH_TOKEN   -> "Token: <jwt>"            ArgoCD reads this
    -H "Authorization"  -> "Authorization: Bearer"   oauth2-proxy reads this

The previous commit sent only the second, which got past the edge but left
Token: empty, so ArgoCD answered "Unauthenticated: no session information". The
one before sent only the first, which the edge ignored. Send both - it is the
same Dex token, and ArgoCD accepts it via allowedAudiences.

`bao login -method=jwt` also does not exist: the OpenBao CLI registers no jwt
auth method, only oidc, which is the browser redirect flow - so it fails with
"Unknown auth method: jwt". Post to the login endpoint directly instead, and do
it as part of toolbox-login so a single login leaves both CLIs usable rather than
requiring a second, differently-shaped one. Roles are tried most-privileged first;
the role's bound_claims decide which one a given user actually gets, so a
rejection there is expected rather than an error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM
The platform now mounts the jwt auth method separately from the web UI's oidc
mount, so its roles no longer need the "-jwt" suffix that only existed to avoid a
name collision on a shared mount. Operators write role=reader and get the reader
policy.

TOOLBOX_BAO_AUTH_PATH (default "jwt") for clusters that mount it elsewhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM
`docker exec` bypasses the ENTRYPOINT, so BAO_ADDR, ARGOCD_SERVER and ARGOCD_OPTS
were unset in any shell started that way. bao then fell back to its own default
address instead of the local proxy - silently, since that default happens to be
the same port on https rather than http.

Move the computation into a sourceable /etc/toolbox-env.sh, read by the entrypoint
and by shells started later (profile.d for login shells, .bashrc for interactive
ones), so `docker exec -it toolbox bash` behaves like the session the entrypoint
set up.

Found while running the flow end to end as a developer would.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM
Matches the convention used by the other GlueOps repositories: release-please
driven by conventional commits, authenticated with the release-please GitHub App
rather than GITHUB_TOKEN so the release tag triggers the image build.

The manifest starts at 0.0.0 and bump-patch-for-minor-pre-major is set, so feat
commits bump the patch while below 1.0.0 and the first release lands on v0.0.1
rather than v0.1.0. No Release-As override is needed, so the behaviour does not
depend on how the first PR happens to be merged.

Also add a .dockerignore: without it the build context carried .git and the docs,
which are not needed to build the image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM
Someone can now point an agent at this repo and ask a question about their
cluster, and the agent has what it needs to get in.

Scoped to authentication only. An agent already knows argocd and bao; what it
cannot infer is how to get access, and the parts it gets wrong without being told:

- `docker run -it` is useless to it. No TTY means nothing to type into and no way
  to read the device URL back out. Run detached, then docker exec.
- toolbox-login blocks until a human approves. It has to run in the background,
  and the URL has to be relayed to the person rather than polled silently - they
  cannot approve something they have not been shown.
- `docker exec` bypasses the ENTRYPOINT, so commands need a login shell to pick up
  /etc/toolbox-env.sh. Without it argocd and bao are misconfigured.

Also states the obvious-to-a-human rules worth making explicit: do not print the
token, default to the read-only role, do not mutate anything unasked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM
@venkatamutyala
venkatamutyala force-pushed the feat/dockerised-toolbox branch from 5794ef9 to ed89f1f Compare August 30, 2026 15:56
@venkatamutyala
venkatamutyala merged commit 8c414e4 into main Aug 30, 2026
3 checks passed
@venkatamutyala
venkatamutyala deleted the feat/dockerised-toolbox branch August 30, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant