From 0887f51ef54c8658b73711be59972e63c4496912 Mon Sep 17 00:00:00 2001 From: Venkat Date: Sun, 30 Aug 2026 14:48:44 +0000 Subject: [PATCH 1/7] feat: dockerised toolbox with the platform CLIs preauthenticated 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) Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM --- .github/workflows/build.yml | 67 +++++++++++++ Dockerfile | 60 +++++++++++ README.md | 106 +++++++++++++++++++- bin/argocd | 17 ++++ bin/toolbox-login | 25 +++++ bin/toolbox-proxy | 147 +++++++++++++++++++++++++++ bin/toolbox-token | 27 +++++ entrypoint.sh | 29 ++++++ lib/toolbox_auth.py | 193 ++++++++++++++++++++++++++++++++++++ 9 files changed, 670 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build.yml create mode 100644 Dockerfile create mode 100755 bin/argocd create mode 100755 bin/toolbox-login create mode 100755 bin/toolbox-proxy create mode 100755 bin/toolbox-token create mode 100755 entrypoint.sh create mode 100644 lib/toolbox_auth.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..5953ae3 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,67 @@ +name: build + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Apple Silicon is a first-class target for this image: developers run it on + # their laptops. QEMU lets the amd64 runner build the arm64 variant too. + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # A PR build is not pushed, so load the native image and check the tools + # actually run - a wrong-architecture binary would otherwise pass unnoticed. + - name: Smoke test (native arch) + if: github.event_name == 'pull_request' + run: | + docker buildx build --load -t toolbox:ci . + docker run --rm --entrypoint bao toolbox:ci version + docker run --rm --entrypoint argocd.real toolbox:ci version --client + docker run --rm --entrypoint python3 toolbox:ci \ + -c "import sys; sys.path.insert(0,'/opt/toolbox/lib'); import toolbox_auth; print('lib ok')" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b57b6b7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +# GlueOps toolbox - the platform CLIs, preconfigured to authenticate through the +# oauth2-proxy edge. Developers run this instead of installing anything locally. +FROM debian:12-slim + +ARG ARGOCD_VERSION=v3.3.12 +ARG OPENBAO_VERSION=2.4.4 + +# Supplied automatically by BuildKit for the platform being built. Deliberately +# left without a default: a default would silently produce an arm64 image full of +# amd64 binaries when someone builds natively on an Apple Silicon Mac. +ARG TARGETARCH + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl python3 jq less git bash \ + && rm -rf /var/lib/apt/lists/* + +# argocd is installed as argocd.real; bin/argocd wraps it to attach the edge token. +RUN set -eux; \ + : "${TARGETARCH:?BuildKit must supply TARGETARCH - build with docker buildx}"; \ + curl -fsSL -o /usr/local/bin/argocd.real \ + "https://github.com/argoproj/argo-cd/releases/download/${ARGOCD_VERSION}/argocd-linux-${TARGETARCH}"; \ + chmod +x /usr/local/bin/argocd.real; \ + /usr/local/bin/argocd.real version --client >/dev/null + +# OpenBao names its tarballs by uname -m (x86_64), not by Docker's TARGETARCH (amd64). +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) BAO_ARCH=x86_64 ;; \ + arm64) BAO_ARCH=arm64 ;; \ + *) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + curl -fsSL -o /tmp/bao.tar.gz \ + "https://github.com/openbao/openbao/releases/download/v${OPENBAO_VERSION}/bao_${OPENBAO_VERSION}_Linux_${BAO_ARCH}.tar.gz"; \ + tar -xzf /tmp/bao.tar.gz -C /usr/local/bin bao; \ + chmod +x /usr/local/bin/bao; \ + rm -f /tmp/bao.tar.gz; \ + /usr/local/bin/bao version >/dev/null + +COPY lib/ /opt/toolbox/lib/ +COPY bin/ /usr/local/bin/ +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/toolbox-token /usr/local/bin/toolbox-proxy \ + /usr/local/bin/toolbox-login /usr/local/bin/argocd \ + /usr/local/bin/entrypoint.sh + +# Unprivileged, with the token-cache directory created up front and owned by the +# runtime user - otherwise a mounted volume lands root-owned and the cache write +# fails. Deliberately no VOLUME directive: it would create a fresh anonymous +# volume on every `docker run`, so the cache would never survive a restart and +# developers would re-authenticate every time. Persistence is opt-in, by mounting +# a named volume over this path (see README). +RUN useradd -m -u 1000 -s /bin/bash toolbox \ + && mkdir -p /home/toolbox/.config/glueops \ + && chown -R toolbox:toolbox /home/toolbox +USER toolbox +WORKDIR /home/toolbox + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["bash"] diff --git a/README.md b/README.md index bfdbd0b..fd8badb 100644 --- a/README.md +++ b/README.md @@ -1 +1,105 @@ -# template +# GlueOps Toolbox + +The platform CLIs in one container, already wired up to authenticate. Developers +don't install `argocd`, `bao`, or anything else locally — and they don't need +`kubectl` or cluster access. + +```bash +docker run -it --rm \ + -e TOOLBOX_CAPTAIN_DOMAIN= \ + -v glueops-toolbox:/home/toolbox/.config/glueops \ + ghcr.io/glueops/toolbox:latest +``` + +You'll be given a URL to open and approve with GitHub. After that: + +```bash +argocd app list +bao kv get secret/my-app +``` + +No flags, no `argocd login`, no `bao login`. Both CLIs behave normally. + +> Mount the named volume. Without it the login is thrown away when the container +> exits and you re-authenticate every run. + +## Why a container + +Everything on a GlueOps cluster sits behind oauth2-proxy, which expects a browser +session cookie. CLIs don't have one, so out of the box every request is answered +with a login redirect. Getting past that needs a token, and — for OpenBao — a +header on every single invocation that no shell wrapper can place reliably. + +The container handles all of it, so the CLIs are just the CLIs. + +## Commands + +| | | +|---|---| +| `argocd …` | ArgoCD CLI. Authenticated per-invocation, so a long shell never goes stale. | +| `bao …` | OpenBao CLI, pointed at a local proxy that attaches your token. | +| `toolbox-login` | Authenticate. Runs automatically on an interactive start. | +| `toolbox-login --force` | Re-authenticate, e.g. to switch accounts. | +| `toolbox-token` | Print the raw token, for scripting. | + +## Configuration + +| Variable | Default | | +|---|---|---| +| `TOOLBOX_CAPTAIN_DOMAIN` | — | **Required.** e.g. `nonprod.example.onglueops.rocks` | +| `TOOLBOX_CLIENT_ID` | `toolbox` | Dex client used to mint the token | +| `TOOLBOX_DEX_URL` | `https://dex.$DOMAIN` | | +| `TOOLBOX_BAO_UPSTREAM` | `https://vault.$DOMAIN` | | +| `ARGOCD_SERVER` | `argocd.$DOMAIN` | | +| `TOOLBOX_PROXY_PORT` | `8200` | Loopback port the OpenBao proxy listens on | +| `TOOLBOX_TOKEN_CACHE` | `~/.config/glueops/toolbox-token.json` | | + +## How it works + +**Getting a token.** `toolbox-login` runs the OIDC **device flow** against Dex. +That matters: there's no loopback listener and no redirect URI, so it works from +inside a container whose browser is on the host — a `localhost:8085` callback +would not. Dex issues a refresh token alongside, so the browser step happens once +rather than daily. + +**ArgoCD** accepts that token directly (it's configured with the toolbox audience +in `allowedAudiences`), so one token satisfies both the edge and ArgoCD itself. +The `argocd` wrapper sets `ARGOCD_AUTH_TOKEN` fresh on each call. + +**OpenBao** can't work that way. Its own credential travels in `X-Vault-Token`, +and the edge needs an `Authorization` bearer as well. `bao` has a `-header` flag, +but it must sit after the subcommand and before any positional argument — + +``` +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 +``` + +— and since `bao kv list secret` is indistinguishable from a subcommand plus a +path, no wrapper can place it correctly in general. So instead the container runs +a small loopback proxy that adds the header and forwards upstream, and points +`BAO_ADDR` at it. `bao` then needs no flags at all and scripts work unmodified. + +The proxy binds to `127.0.0.1` only — it attaches your credential to whatever it +forwards, so it must never be exposed. + +## Platforms + +Built for `linux/amd64` and `linux/arm64`, so Apple Silicon is native — no +emulation, no Rosetta. + +## Building + +```bash +docker buildx build --platform linux/amd64,linux/arm64 -t toolbox . +``` + +`TARGETARCH` comes from BuildKit and has no default on purpose: a default would +silently put amd64 binaries in an arm64 image when built natively on a Mac. + +## Cluster prerequisites + +The platform must have a public Dex client matching `TOOLBOX_CLIENT_ID`, that +audience accepted by oauth2-proxy (`oidc_extra_audiences`) and by ArgoCD +(`allowedAudiences`), and jwt-type roles in OpenBao bound to it. diff --git a/bin/argocd b/bin/argocd new file mode 100755 index 0000000..a2cd78f --- /dev/null +++ b/bin/argocd @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# argocd, with the edge token attached. +# +# The token `argocd login --sso` would obtain is a Dex id_token, and ArgoCD accepts +# the toolbox audience (allowedAudiences), so one token satisfies both the edge and +# ArgoCD itself - `argocd login` is unnecessary. Setting it fresh per call also means +# a long-lived shell never goes stale. +# +# --no-login: never start a device flow from here. A browser prompt appearing in the +# middle of someone's command (or inside a pipeline) is worse than a clear error. +set -euo pipefail +if ! ARGOCD_AUTH_TOKEN="$(toolbox-token --no-login)"; then + echo "toolbox: not authenticated. Run 'toolbox-login' first." >&2 + exit 1 +fi +export ARGOCD_AUTH_TOKEN +exec /usr/local/bin/argocd.real "$@" diff --git a/bin/toolbox-login b/bin/toolbox-login new file mode 100755 index 0000000..3399ce3 --- /dev/null +++ b/bin/toolbox-login @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Authenticate the toolbox to the GlueOps platform. + + toolbox-login # only prompts if there is no usable token + toolbox-login --force # always re-authenticate (e.g. switch account) + +Uses the OIDC device flow, so there is no loopback listener and no redirect URI: +you open a URL on whatever machine has your browser and approve with GitHub. The +token is cached and refreshed silently afterwards, so this is rarely needed twice. +""" +import sys + +sys.path.insert(0, "/opt/toolbox/lib") +import toolbox_auth # noqa: E402 + +if __name__ == "__main__": + if "--help" in sys.argv or "-h" in sys.argv: + print(__doc__) + sys.exit(0) + force = "--force" in sys.argv + if not force and toolbox_auth.get_token(interactive=False): + print("Already authenticated.", file=sys.stderr) + sys.exit(0) + toolbox_auth.get_token(force_login=force) + print("Authenticated.", file=sys.stderr) diff --git a/bin/toolbox-proxy b/bin/toolbox-proxy new file mode 100755 index 0000000..c9a449e --- /dev/null +++ b/bin/toolbox-proxy @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Local proxy that puts a Dex id_token on every OpenBao request. + +The `bao` CLI carries its own credential in X-Vault-Token and has no way to also +send an Authorization bearer, which is what the GlueOps edge requires: `-header` +exists but must be repeated on every invocation, after the subcommand and before +any positional argument, so no shell wrapper can place it reliably. + +So instead of contorting the CLI, this listens on loopback, adds the header, and +forwards upstream. `bao` then behaves completely normally - BAO_ADDR points here, +no flags, and scripts that shell out to `bao` work unmodified. + +Only ever bind this to loopback: it attaches your credential to whatever it +forwards. +""" +import http.server +import os +import ssl +import sys +import urllib.error +import urllib.request + +sys.path.insert(0, "/opt/toolbox/lib") +import toolbox_auth # noqa: E402 + +UPSTREAM = os.environ.get("TOOLBOX_BAO_UPSTREAM") or ( + f"https://vault.{toolbox_auth.captain_domain()}" +) +LISTEN_PORT = int(os.environ.get("TOOLBOX_PROXY_PORT", "8200")) + +# Hop-by-hop headers must not be forwarded (RFC 7230 6.1). +HOP_BY_HOP = { + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "transfer-encoding", "upgrade", +} + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """The edge answers an unauthenticated request with a 302 to its login page. + Following that would hand the CLI an HTML page to parse; surfacing it as an + error says what actually went wrong.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +_ctx = ssl.create_default_context() +if os.environ.get("TOOLBOX_INSECURE_SKIP_VERIFY", "").lower() in ("1", "true", "yes"): + _ctx.check_hostname = False + _ctx.verify_mode = ssl.CERT_NONE + + +class Handler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *args): # quiet unless asked + if os.environ.get("TOOLBOX_PROXY_VERBOSE"): + sys.stderr.write("toolbox-proxy: " + fmt % args + "\n") + + def _forward(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) if length else None + + req = urllib.request.Request(UPSTREAM + self.path, data=body, method=self.command) + for k, v in self.headers.items(): + if k.lower() in HOP_BY_HOP or k.lower() in ("host", "content-length"): + continue + req.add_header(k, v) + + # The whole point: prove to the edge who we are. X-Vault-Token, which the + # CLI sets, takes precedence inside OpenBao, so this never shadows it. + token = toolbox_auth.get_token(interactive=False) + if token is None: + # OpenBao's own error shape, so `bao` prints the message plainly + # instead of dumping an HTML error page into the terminal. + self._json(511, {"errors": [ + "toolbox: not authenticated - run 'toolbox-login', then retry" + ]}) + return + req.add_header("Authorization", "Bearer " + token) + + try: + with _opener.open(req, timeout=60) as r: + self._relay(r.status, r.headers, r.read()) + except urllib.error.HTTPError as e: + if e.code in (301, 302, 303, 307, 308): + # A redirect here is the edge bouncing us to oauth2-proxy's login + # page, i.e. it did not accept the token. + self._json(401, {"errors": [ + "toolbox: the platform edge rejected this token - " + "run 'toolbox-login --force' to re-authenticate" + ]}) + return + # OpenBao returns meaningful bodies on 4xx; pass them straight through. + self._relay(e.code, e.headers, e.read()) + except urllib.error.URLError as e: + self._json(502, {"errors": [f"toolbox: upstream unreachable: {e.reason}"]}) + + def _json(self, status, obj): + import json as _json_mod + + payload = _json_mod.dumps(obj).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def _relay(self, status, headers, payload): + self.send_response(status) + for k, v in headers.items(): + if k.lower() in HOP_BY_HOP or k.lower() == "content-length": + continue + self.send_header(k, v) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def handle_one_request(self): + # bao closes the connection as soon as it has what it needs; that is not + # an error worth a traceback. + try: + super().handle_one_request() + except (BrokenPipeError, ConnectionResetError): + self.close_connection = True + + do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = _forward + + def do_LIST(self): # OpenBao's non-standard verb + self._forward() + + +_opener = urllib.request.build_opener( + _NoRedirect(), urllib.request.HTTPSHandler(context=_ctx) +) + + +class Server(http.server.ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + +if __name__ == "__main__": + srv = Server(("127.0.0.1", LISTEN_PORT), Handler) + sys.stderr.write( + f"toolbox-proxy: 127.0.0.1:{LISTEN_PORT} -> {UPSTREAM}\n" + ) + srv.serve_forever() diff --git a/bin/toolbox-token b/bin/toolbox-token new file mode 100755 index 0000000..9dd2ad2 --- /dev/null +++ b/bin/toolbox-token @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Print a Dex id_token for the GlueOps edge. + + toolbox-token # cached, refreshed silently when expired + toolbox-token --login # force a fresh browser login (e.g. switch account) + toolbox-token --no-login # exit 1 rather than prompt; for scripts and wrappers + +The token goes to stdout and everything else to stderr, so it composes: + + export ARGOCD_AUTH_TOKEN=$(toolbox-token) +""" +import sys + +sys.path.insert(0, "/opt/toolbox/lib") +import toolbox_auth # noqa: E402 + +if __name__ == "__main__": + if "--help" in sys.argv or "-h" in sys.argv: + print(__doc__) + sys.exit(0) + if "--no-login" in sys.argv: + tok = toolbox_auth.get_token(interactive=False) + if tok is None: + sys.exit(1) + print(tok) + sys.exit(0) + print(toolbox_auth.get_token(force_login="--login" in sys.argv)) diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..74146ed --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Bring up the local OpenBao proxy, then hand over to the user's command. +set -euo pipefail + +: "${TOOLBOX_CAPTAIN_DOMAIN:?set TOOLBOX_CAPTAIN_DOMAIN, e.g. nonprod.example.onglueops.rocks}" + +export ARGOCD_SERVER="${ARGOCD_SERVER:-argocd.${TOOLBOX_CAPTAIN_DOMAIN}}" +# Traefik terminates TLS and argocd-server runs insecure behind it, so the CLI +# has to speak gRPC-web rather than HTTP/2. +export ARGOCD_OPTS="${ARGOCD_OPTS:---grpc-web}" + +PROXY_PORT="${TOOLBOX_PROXY_PORT:-8200}" +export BAO_ADDR="${BAO_ADDR:-http://127.0.0.1:${PROXY_PORT}}" +export VAULT_ADDR="$BAO_ADDR" + +toolbox-proxy & +for _ in $(seq 1 50); do + if (exec 3<>/dev/tcp/127.0.0.1/"$PROXY_PORT") 2>/dev/null; then exec 3>&- 3<&-; break; fi + sleep 0.1 +done + +# Log in up front when there is a terminal, so the prompt appears here rather than +# from the background proxy midway through someone's first command. Without a tty +# (CI, `docker run` without -it) skip it: the proxy answers 511 with instructions. +if [ -t 0 ]; then + toolbox-login || true +fi + +exec "$@" diff --git a/lib/toolbox_auth.py b/lib/toolbox_auth.py new file mode 100644 index 0000000..d69e58d --- /dev/null +++ b/lib/toolbox_auth.py @@ -0,0 +1,193 @@ +"""Dex device-flow tokens for the GlueOps toolbox. + +Everything behind the GlueOps edge (oauth2-proxy) will accept a Dex-issued +id_token in place of a browser session cookie. This module obtains one and keeps +it fresh, so the CLIs in this image can be used normally. + +The device flow is deliberate: it needs no loopback listener and no redirect URI, +so it works from inside a container whose browser lives on the host. + +stdlib only. +""" + +import json +import os +import stat +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +# Refresh early rather than hand out a token that expires mid-request. +EXPIRY_SKEW = 60 + + +def _env(name, default=None, required=False): + v = os.environ.get(name, default) + if required and not v: + sys.exit(f"toolbox: {name} is not set (see README)") + return v + + +def captain_domain(): + return _env("TOOLBOX_CAPTAIN_DOMAIN", required=True) + + +def dex_url(): + return _env("TOOLBOX_DEX_URL") or f"https://dex.{captain_domain()}" + + +def client_id(): + return _env("TOOLBOX_CLIENT_ID", "toolbox") + + +def cache_path(): + return os.path.expanduser( + _env("TOOLBOX_TOKEN_CACHE", "~/.config/glueops/toolbox-token.json") + ) + + +def log(msg): + print(msg, file=sys.stderr) + + +def _post(path, data): + body = urllib.parse.urlencode(data).encode() + req = urllib.request.Request(dex_url() + path, data=body, method="POST") + req.add_header("Content-Type", "application/x-www-form-urlencoded") + try: + with urllib.request.urlopen(req, timeout=30) as r: + return r.status, json.load(r) + except urllib.error.HTTPError as e: + raw = e.read() + try: + return e.code, json.loads(raw) + except ValueError: + return e.code, {"error": raw.decode(errors="replace")[:200]} + except urllib.error.URLError as e: + sys.exit(f"toolbox: cannot reach Dex at {dex_url()}: {e.reason}") + + +def _read_cache(): + try: + with open(cache_path()) as f: + return json.load(f) + except (OSError, ValueError): + return {} + + +def _write_cache(obj): + p = cache_path() + os.makedirs(os.path.dirname(p), exist_ok=True) + tmp = p + ".tmp" + with open(tmp, "w") as f: + json.dump(obj, f) + os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR) # it is a credential + os.replace(tmp, p) + + +def _expiry(token): + """`exp` from a JWT, unverified - verification is the server's job. We only + need to know whether it is still worth sending.""" + try: + import base64 + + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload)).get("exp", 0) + except Exception: + return 0 + + +def _valid(token): + return bool(token) and _expiry(token) - EXPIRY_SKEW > time.time() + + +def _refresh(refresh_token, quiet=False): + status, body = _post( + "/token", + { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id(), + }, + ) + if status == 200 and body.get("id_token"): + return body + if not quiet: + log(f"toolbox: refresh failed ({status}); falling back to a browser login") + return None + + +def _device_flow(): + status, body = _post( + "/device/code", + { + "client_id": client_id(), + "scope": "openid profile email groups offline_access", + }, + ) + if status != 200: + sys.exit(f"toolbox: could not start device flow: {status} {body}") + + log("") + log(" Open this URL in your browser and approve with GitHub:") + log(f" {body['verification_uri_complete']}") + log("") + log(f" code {body['user_code']} - expires in {body['expires_in'] // 60} minutes") + log("") + + interval = body.get("interval", 5) + deadline = time.time() + body.get("expires_in", 300) + while time.time() < deadline: + time.sleep(interval) + status, tok = _post( + "/token", + { + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": body["device_code"], + "client_id": client_id(), + }, + ) + if status == 200 and tok.get("id_token"): + log(" Authenticated.") + return tok + err = tok.get("error") + if err == "authorization_pending": + continue + if err == "slow_down": + interval += 5 + continue + sys.exit(f"toolbox: device flow failed: {tok}") + + sys.exit("toolbox: timed out waiting for approval") + + +def get_token(force_login=False, interactive=True): + """A valid Dex id_token: from cache, by silent refresh, or by browser login. + + With interactive=False, returns None rather than starting a browser login. + The proxy uses that: a device-flow prompt printed from a background process + while the CLI hangs is bewildering, and concurrent requests would each start + their own flow. Interactive login belongs in `toolbox-login`. + """ + cache = {} if force_login else _read_cache() + + if _valid(cache.get("id_token")): + return cache["id_token"] + + tok = None + if not force_login and cache.get("refresh_token"): + tok = _refresh(cache["refresh_token"], quiet=not interactive) + if tok is None: + if not interactive: + return None + tok = _device_flow() + + # Dex does not always return a new refresh token on refresh; keep the old one. + if not tok.get("refresh_token") and cache.get("refresh_token"): + tok["refresh_token"] = cache["refresh_token"] + + _write_cache(tok) + return tok["id_token"] From 62a4f3fdcba52deebd918c5c8b360fe283593e0e Mon Sep 17 00:00:00 2001 From: Venkat Date: Sun, 30 Aug 2026 15:04:00 +0000 Subject: [PATCH 2/7] fix: send the edge token in a header argocd actually transmits 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) Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM --- README.md | 7 ++++++- bin/argocd | 21 ++++++++++++--------- bin/toolbox-proxy | 21 +++++++++++++++------ 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index fd8badb..06ae83e 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,12 @@ rather than daily. **ArgoCD** accepts that token directly (it's configured with the toolbox audience in `allowedAudiences`), so one token satisfies both the edge and ArgoCD itself. -The `argocd` wrapper sets `ARGOCD_AUTH_TOKEN` fresh on each call. + +It has to be passed as `-H "Authorization: …"`, though — **not** `ARGOCD_AUTH_TOKEN`. +The CLI sends that one in a `Token:` header, which oauth2-proxy doesn't read, so +the edge sees no credential and bounces the request to a login page; the CLI then +fails with the rather unhelpful `rpc error: unexpected EOF`. `-H` is a global flag, +so the wrapper just prepends it and sets the token fresh on every call. **OpenBao** can't work that way. Its own credential travels in `X-Vault-Token`, and the edge needs an `Authorization` bearer as well. `bao` has a `-header` flag, diff --git a/bin/argocd b/bin/argocd index a2cd78f..47420c5 100755 --- a/bin/argocd +++ b/bin/argocd @@ -1,17 +1,20 @@ #!/usr/bin/env bash # argocd, with the edge token attached. # -# The token `argocd login --sso` would obtain is a Dex id_token, and ArgoCD accepts -# the toolbox audience (allowedAudiences), so one token satisfies both the edge and -# ArgoCD itself - `argocd login` is unnecessary. Setting it fresh per call also means -# a long-lived shell never goes stale. +# ARGOCD_AUTH_TOKEN is NOT enough. The CLI sends its credential in a `Token:` +# header, which oauth2-proxy does not look at - it reads `Authorization`. So with +# only ARGOCD_AUTH_TOKEN set, the edge sees no bearer at all and answers every +# request with a login redirect, which surfaces as "rpc error: unexpected EOF". # -# --no-login: never start a device flow from here. A browser prompt appearing in the -# middle of someone's command (or inside a pipeline) is worse than a clear error. +# ARGOCD_AUTH_TOKEN=x -> Token: x (edge ignores it) +# -H "Authorization: …" -> Authorization: Bearer x (edge accepts it) +# +# -H is a global flag, so it can go before the subcommand and this wrapper stays +# trivial. ArgoCD itself also accepts a bearer, and it is configured with the +# toolbox audience in allowedAudiences, so the one header satisfies both layers. set -euo pipefail -if ! ARGOCD_AUTH_TOKEN="$(toolbox-token --no-login)"; then +if ! TOKEN="$(toolbox-token --no-login)"; then echo "toolbox: not authenticated. Run 'toolbox-login' first." >&2 exit 1 fi -export ARGOCD_AUTH_TOKEN -exec /usr/local/bin/argocd.real "$@" +exec /usr/local/bin/argocd.real -H "Authorization: Bearer ${TOKEN}" "$@" diff --git a/bin/toolbox-proxy b/bin/toolbox-proxy index c9a449e..dbcc6bc 100755 --- a/bin/toolbox-proxy +++ b/bin/toolbox-proxy @@ -83,12 +83,21 @@ class Handler(http.server.BaseHTTPRequestHandler): self._relay(r.status, r.headers, r.read()) except urllib.error.HTTPError as e: if e.code in (301, 302, 303, 307, 308): - # A redirect here is the edge bouncing us to oauth2-proxy's login - # page, i.e. it did not accept the token. - self._json(401, {"errors": [ - "toolbox: the platform edge rejected this token - " - "run 'toolbox-login --force' to re-authenticate" - ]}) + # Distinguish "the edge bounced us to a login page" from any other + # redirect. Reporting every 3xx as an auth failure once sent us + # chasing the wrong bug: OpenBao's own 403 was being rewritten into + # a login redirect upstream, and this message hid that completely. + loc = e.headers.get("Location", "") + if "/oauth2/start" in loc or "/oauth2/sign_in" in loc: + self._json(401, {"errors": [ + "toolbox: the platform edge rejected this token - " + "run 'toolbox-login --force' to re-authenticate" + ]}) + else: + self._json(502, {"errors": [ + f"toolbox: unexpected redirect from upstream " + f"({e.code} -> {loc or 'no Location header'})" + ]}) return # OpenBao returns meaningful bodies on 4xx; pass them straight through. self._relay(e.code, e.headers, e.read()) From d633d6764e46db055c3260b533921d3b50e706ab Mon Sep 17 00:00:00 2001 From: Venkat Date: Sun, 30 Aug 2026 15:11:10 +0000 Subject: [PATCH 3/7] fix: send both headers argocd needs, and log into OpenBao automatically 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: " 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) Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM --- README.md | 24 +++++++++++++---- bin/argocd | 20 +++++++------- bin/toolbox-login | 30 ++++++++++++++++----- lib/toolbox_auth.py | 65 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 06ae83e..1535b61 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ The container handles all of it, so the CLIs are just the CLIs. | `ARGOCD_SERVER` | `argocd.$DOMAIN` | | | `TOOLBOX_PROXY_PORT` | `8200` | Loopback port the OpenBao proxy listens on | | `TOOLBOX_TOKEN_CACHE` | `~/.config/glueops/toolbox-token.json` | | +| `TOOLBOX_BAO_ROLES` | `editor-jwt,reader-jwt` | OpenBao roles tried at login, in order | ## How it works @@ -65,11 +66,17 @@ rather than daily. **ArgoCD** accepts that token directly (it's configured with the toolbox audience in `allowedAudiences`), so one token satisfies both the edge and ArgoCD itself. -It has to be passed as `-H "Authorization: …"`, though — **not** `ARGOCD_AUTH_TOKEN`. -The CLI sends that one in a `Token:` header, which oauth2-proxy doesn't read, so -the edge sees no credential and bounces the request to a login page; the CLI then -fails with the rather unhelpful `rpc error: unexpected EOF`. `-H` is a global flag, -so the wrapper just prepends it and sets the token fresh on every call. +It needs to go in **two** headers, because each side reads only its own: + +| | header | read by | +|---|---|---| +| `ARGOCD_AUTH_TOKEN` | `Token: ` | ArgoCD | +| `-H "Authorization: …"` | `Authorization: Bearer ` | oauth2-proxy | + +Send only the env var and the edge sees no credential, redirects to a login page, +and the CLI reports `rpc error: unexpected EOF`. Send only `-H` and you get past +the edge with `Token:` empty, so ArgoCD answers `Unauthenticated: no session +information`. The wrapper sets both, fresh on every call. **OpenBao** can't work that way. Its own credential travels in `X-Vault-Token`, and the edge needs an `Authorization` bearer as well. `bao` has a `-header` flag, @@ -86,6 +93,13 @@ path, no wrapper can place it correctly in general. So instead the container run a small loopback proxy that adds the header and forwards upstream, and points `BAO_ADDR` at it. `bao` then needs no flags at all and scripts work unmodified. +`toolbox-login` also exchanges your Dex token for an OpenBao token, so `bao` is +usable immediately. It posts to the login endpoint directly rather than running +`bao login -method=jwt`, because the OpenBao CLI registers no `jwt` method — only +`oidc`, which is the browser redirect flow. Roles are tried most-privileged first +(`TOOLBOX_BAO_ROLES`, default `editor-jwt,reader-jwt`); which one you actually get +is decided by the role's `bound_claims`. + The proxy binds to `127.0.0.1` only — it attaches your credential to whatever it forwards, so it must never be exposed. diff --git a/bin/argocd b/bin/argocd index 47420c5..5d6f815 100755 --- a/bin/argocd +++ b/bin/argocd @@ -1,20 +1,20 @@ #!/usr/bin/env bash -# argocd, with the edge token attached. +# argocd, with credentials for BOTH the edge and ArgoCD itself. # -# ARGOCD_AUTH_TOKEN is NOT enough. The CLI sends its credential in a `Token:` -# header, which oauth2-proxy does not look at - it reads `Authorization`. So with -# only ARGOCD_AUTH_TOKEN set, the edge sees no bearer at all and answers every -# request with a login redirect, which surfaces as "rpc error: unexpected EOF". +# These are two different headers and each side reads only its own: # -# ARGOCD_AUTH_TOKEN=x -> Token: x (edge ignores it) -# -H "Authorization: …" -> Authorization: Bearer x (edge accepts it) +# ARGOCD_AUTH_TOKEN -> "Token: " ArgoCD reads this +# -H "Authorization" -> "Authorization: Bearer" oauth2-proxy reads this # -# -H is a global flag, so it can go before the subcommand and this wrapper stays -# trivial. ArgoCD itself also accepts a bearer, and it is configured with the -# toolbox audience in allowedAudiences, so the one header satisfies both layers. +# Sending only the env var gets a login redirect from the edge, which surfaces as +# "rpc error: unexpected EOF". Sending only -H gets past the edge but leaves +# Token: empty, so ArgoCD answers "Unauthenticated: no session information". +# It is the same Dex token in both; ArgoCD accepts it because the platform lists +# the toolbox audience in allowedAudiences. set -euo pipefail if ! TOKEN="$(toolbox-token --no-login)"; then echo "toolbox: not authenticated. Run 'toolbox-login' first." >&2 exit 1 fi +export ARGOCD_AUTH_TOKEN="$TOKEN" exec /usr/local/bin/argocd.real -H "Authorization: Bearer ${TOKEN}" "$@" diff --git a/bin/toolbox-login b/bin/toolbox-login index 3399ce3..9ca51e4 100755 --- a/bin/toolbox-login +++ b/bin/toolbox-login @@ -7,19 +7,37 @@ Uses the OIDC device flow, so there is no loopback listener and no redirect URI: you open a URL on whatever machine has your browser and approve with GitHub. The token is cached and refreshed silently afterwards, so this is rarely needed twice. + +Also exchanges that token for an OpenBao token, so `bao` is usable straight away +rather than needing a second, differently-shaped login. """ import sys sys.path.insert(0, "/opt/toolbox/lib") import toolbox_auth # noqa: E402 -if __name__ == "__main__": + +def main(): if "--help" in sys.argv or "-h" in sys.argv: print(__doc__) - sys.exit(0) + return 0 + force = "--force" in sys.argv - if not force and toolbox_auth.get_token(interactive=False): + token = None if force else toolbox_auth.get_token(interactive=False) + if token is None: + token = toolbox_auth.get_token(force_login=force) + else: print("Already authenticated.", file=sys.stderr) - sys.exit(0) - toolbox_auth.get_token(force_login=force) - print("Authenticated.", file=sys.stderr) + + # Non-fatal: the edge token is the important one, and someone may legitimately + # have no OpenBao access at all. + role = toolbox_auth.bao_login(token) + if role: + print(f"OpenBao: logged in as {role}.", file=sys.stderr) + else: + print("OpenBao: not logged in (argocd is unaffected).", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lib/toolbox_auth.py b/lib/toolbox_auth.py index d69e58d..94203ed 100644 --- a/lib/toolbox_auth.py +++ b/lib/toolbox_auth.py @@ -191,3 +191,68 @@ def get_token(force_login=False, interactive=True): _write_cache(tok) return tok["id_token"] + + +# --------------------------------------------------------------------------- +# OpenBao +# --------------------------------------------------------------------------- +# OpenBao needs its own token; the Dex token only gets us past the edge. Exchange +# one for the other so a single `toolbox-login` leaves both CLIs usable. +# +# This posts to the login endpoint directly rather than using `bao login`: the +# OpenBao CLI registers no `jwt` auth method (only `oidc`, which is the browser +# redirect flow), so `-method=jwt` fails with "Unknown auth method". + + +def bao_roles(): + return [ + r.strip() + for r in _env("TOOLBOX_BAO_ROLES", "editor-jwt,reader-jwt").split(",") + if r.strip() + ] + + +def bao_addr(): + return _env("BAO_ADDR", "http://127.0.0.1:8200") + + +def bao_token_path(): + return os.path.expanduser(_env("BAO_TOKEN_PATH", "~/.vault-token")) + + +def bao_login(id_token): + """Exchange the Dex token for an OpenBao token. Returns the role used, or None. + + Roles are tried most-privileged first; the role's bound_claims decide which + one a given user is actually entitled to, so a rejection here is expected and + not an error. + """ + last = None + for role in bao_roles(): + body = json.dumps({"role": role, "jwt": id_token}).encode() + req = urllib.request.Request( + bao_addr() + "/v1/auth/oidc/login", data=body, method="POST" + ) + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=30) as r: + data = json.load(r) + except urllib.error.HTTPError as e: + last = e.read().decode(errors="replace")[:200] + continue + except urllib.error.URLError as e: + log(f"toolbox: cannot reach OpenBao at {bao_addr()}: {e.reason}") + return None + + tok = (data.get("auth") or {}).get("client_token") + if tok: + p = bao_token_path() + os.makedirs(os.path.dirname(p) or ".", exist_ok=True) + with open(p, "w") as f: + f.write(tok) + os.chmod(p, stat.S_IRUSR | stat.S_IWUSR) + return role + + if last: + log(f"toolbox: OpenBao login failed for {bao_roles()}: {last}") + return None From 750a1e44b7587f27c57c997192cd8fd213260d58 Mon Sep 17 00:00:00 2001 From: Venkat Date: Sun, 30 Aug 2026 15:30:04 +0000 Subject: [PATCH 4/7] refactor: log in through the CLI's own auth mount, with plain role names 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) Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM --- README.md | 12 +++++++++--- lib/toolbox_auth.py | 11 +++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1535b61..32c9d84 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,8 @@ The container handles all of it, so the CLIs are just the CLIs. | `ARGOCD_SERVER` | `argocd.$DOMAIN` | | | `TOOLBOX_PROXY_PORT` | `8200` | Loopback port the OpenBao proxy listens on | | `TOOLBOX_TOKEN_CACHE` | `~/.config/glueops/toolbox-token.json` | | -| `TOOLBOX_BAO_ROLES` | `editor-jwt,reader-jwt` | OpenBao roles tried at login, in order | +| `TOOLBOX_BAO_ROLES` | `editor,reader` | OpenBao roles tried at login, in order | +| `TOOLBOX_BAO_AUTH_PATH` | `jwt` | OpenBao auth mount the CLI logs in through | ## How it works @@ -97,8 +98,13 @@ a small loopback proxy that adds the header and forwards upstream, and points usable immediately. It posts to the login endpoint directly rather than running `bao login -method=jwt`, because the OpenBao CLI registers no `jwt` method — only `oidc`, which is the browser redirect flow. Roles are tried most-privileged first -(`TOOLBOX_BAO_ROLES`, default `editor-jwt,reader-jwt`); which one you actually get -is decided by the role's `bound_claims`. +(`TOOLBOX_BAO_ROLES`, default `editor,reader`); which one you actually get is +decided by the role's `bound_claims`. + +Set `TOOLBOX_BAO_ROLES=reader` to deliberately hold only read access for a +session. The CLI roles live on their own `auth/jwt` mount, separate from the web +UI's `auth/oidc`, which is why they can share the names of the policies they +grant. The proxy binds to `127.0.0.1` only — it attaches your credential to whatever it forwards, so it must never be exposed. diff --git a/lib/toolbox_auth.py b/lib/toolbox_auth.py index 94203ed..737a2c1 100644 --- a/lib/toolbox_auth.py +++ b/lib/toolbox_auth.py @@ -202,16 +202,23 @@ def get_token(force_login=False, interactive=True): # This posts to the login endpoint directly rather than using `bao login`: the # OpenBao CLI registers no `jwt` auth method (only `oidc`, which is the browser # redirect flow), so `-method=jwt` fails with "Unknown auth method". +# +# The CLI has its own mount (auth/jwt) separate from the web UI's (auth/oidc), so +# its roles can carry the same names as the policies they grant. def bao_roles(): return [ r.strip() - for r in _env("TOOLBOX_BAO_ROLES", "editor-jwt,reader-jwt").split(",") + for r in _env("TOOLBOX_BAO_ROLES", "editor,reader").split(",") if r.strip() ] +def bao_auth_path(): + return _env("TOOLBOX_BAO_AUTH_PATH", "jwt").strip("/") + + def bao_addr(): return _env("BAO_ADDR", "http://127.0.0.1:8200") @@ -231,7 +238,7 @@ def bao_login(id_token): for role in bao_roles(): body = json.dumps({"role": role, "jwt": id_token}).encode() req = urllib.request.Request( - bao_addr() + "/v1/auth/oidc/login", data=body, method="POST" + f"{bao_addr()}/v1/auth/{bao_auth_path()}/login", data=body, method="POST" ) req.add_header("Content-Type", "application/json") try: From d4e527d458864129c24d8021610ca056ff53f7b0 Mon Sep 17 00:00:00 2001 From: Venkat Date: Sun, 30 Aug 2026 15:33:57 +0000 Subject: [PATCH 5/7] fix: propagate the toolbox environment to shells started by docker exec `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) Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM --- Dockerfile | 5 +++++ bin/toolbox-env | 14 ++++++++++++++ entrypoint.sh | 8 ++------ 3 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 bin/toolbox-env diff --git a/Dockerfile b/Dockerfile index b57b6b7..63d8446 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,10 @@ RUN set -eux; \ COPY lib/ /opt/toolbox/lib/ COPY bin/ /usr/local/bin/ COPY entrypoint.sh /usr/local/bin/entrypoint.sh +COPY bin/toolbox-env /etc/toolbox-env.sh +# `docker exec` bypasses the ENTRYPOINT, so wire the same environment into shells +# started that way - interactive ones read .bashrc, login ones read profile.d. +RUN printf '. /etc/toolbox-env.sh\n' > /etc/profile.d/toolbox.sh RUN chmod +x /usr/local/bin/toolbox-token /usr/local/bin/toolbox-proxy \ /usr/local/bin/toolbox-login /usr/local/bin/argocd \ /usr/local/bin/entrypoint.sh @@ -52,6 +56,7 @@ RUN chmod +x /usr/local/bin/toolbox-token /usr/local/bin/toolbox-proxy \ # a named volume over this path (see README). RUN useradd -m -u 1000 -s /bin/bash toolbox \ && mkdir -p /home/toolbox/.config/glueops \ + && printf '. /etc/toolbox-env.sh\n' >> /home/toolbox/.bashrc \ && chown -R toolbox:toolbox /home/toolbox USER toolbox WORKDIR /home/toolbox diff --git a/bin/toolbox-env b/bin/toolbox-env new file mode 100644 index 0000000..432c512 --- /dev/null +++ b/bin/toolbox-env @@ -0,0 +1,14 @@ +# Sourced, not executed. Computes the environment both the entrypoint and any +# later shell need, so `docker exec` into a running toolbox behaves like the +# session the entrypoint set up - exec skips the ENTRYPOINT entirely, so without +# this BAO_ADDR is unset there and bao silently talks to its own default address +# instead of the local proxy. +: "${TOOLBOX_CAPTAIN_DOMAIN:=}" +if [ -n "$TOOLBOX_CAPTAIN_DOMAIN" ]; then + export ARGOCD_SERVER="${ARGOCD_SERVER:-argocd.${TOOLBOX_CAPTAIN_DOMAIN}}" + # Traefik terminates TLS and argocd-server runs insecure behind it, so the + # CLI has to speak gRPC-web rather than HTTP/2. + export ARGOCD_OPTS="${ARGOCD_OPTS:---grpc-web}" + export BAO_ADDR="${BAO_ADDR:-http://127.0.0.1:${TOOLBOX_PROXY_PORT:-8200}}" + export VAULT_ADDR="$BAO_ADDR" +fi diff --git a/entrypoint.sh b/entrypoint.sh index 74146ed..c40fef5 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -4,14 +4,10 @@ set -euo pipefail : "${TOOLBOX_CAPTAIN_DOMAIN:?set TOOLBOX_CAPTAIN_DOMAIN, e.g. nonprod.example.onglueops.rocks}" -export ARGOCD_SERVER="${ARGOCD_SERVER:-argocd.${TOOLBOX_CAPTAIN_DOMAIN}}" -# Traefik terminates TLS and argocd-server runs insecure behind it, so the CLI -# has to speak gRPC-web rather than HTTP/2. -export ARGOCD_OPTS="${ARGOCD_OPTS:---grpc-web}" +# shellcheck source=/dev/null +. /etc/toolbox-env.sh PROXY_PORT="${TOOLBOX_PROXY_PORT:-8200}" -export BAO_ADDR="${BAO_ADDR:-http://127.0.0.1:${PROXY_PORT}}" -export VAULT_ADDR="$BAO_ADDR" toolbox-proxy & for _ in $(seq 1 50); do From d0685c6c774887c5bf28b01c2d8e61ba37d6a175 Mon Sep 17 00:00:00 2001 From: Venkat Date: Sun, 30 Aug 2026 15:48:25 +0000 Subject: [PATCH 6/7] ci: release with release-please, starting at v0.0.1 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) Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM --- .dockerignore | 5 +++++ .github/workflows/release-please.yaml | 29 +++++++++++++++++++++++++++ .release-please-manifest.json | 3 +++ README.md | 7 +++++++ release-please-config.json | 26 ++++++++++++++++++++++++ 5 files changed, 70 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/release-please.yaml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4e30e9b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.github +*.md +release-please-config.json +.release-please-manifest.json diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml new file mode 100644 index 0000000..7a8b172 --- /dev/null +++ b/.github/workflows/release-please.yaml @@ -0,0 +1,29 @@ +name: release-please + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + # Authenticate with the public-release-please GitHub App (NOT GITHUB_TOKEN). + # App installation tokens trigger downstream workflows (so the release tag + # triggers the image build) and let release PRs run required checks. + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: app-token + with: + app-id: ${{ secrets.RELEASE_PLEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_PLEASE_APP_PRIVATE_KEY }} + + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ steps.app-token.outputs.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/README.md b/README.md index 32c9d84..d660934 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,13 @@ forwards, so it must never be exposed. Built for `linux/amd64` and `linux/arm64`, so Apple Silicon is native — no emulation, no Rosetta. +## Releases + +Tagged with [release-please](https://github.com/googleapis/release-please) from +conventional commits on `main`. A release tag publishes +`ghcr.io/glueops/toolbox:`, `:.` and `:latest`, all +multi-arch. Pin a version in anything automated; `:latest` is fine for people. + ## Building ```bash diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..e386a06 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "include-component-in-tag": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "revert", "section": "Reverts" }, + { "type": "docs", "section": "Documentation", "hidden": false }, + { "type": "style", "section": "Styles", "hidden": false }, + { "type": "chore", "section": "Miscellaneous Chores", "hidden": false }, + { "type": "refactor", "section": "Code Refactoring", "hidden": false }, + { "type": "test", "section": "Tests", "hidden": false }, + { "type": "build", "section": "Build System", "hidden": false }, + { "type": "ci", "section": "Continuous Integration", "hidden": false }, + { "type": "deps", "section": "Dependencies", "hidden": false } + ], + "packages": { + ".": { + "release-type": "simple", + "package-name": "toolbox" + } + } +} From ed89f1f191a0f923daf8cbc92bb46685512cfbaf Mon Sep 17 00:00:00 2001 From: Venkat Date: Sun, 30 Aug 2026 15:55:16 +0000 Subject: [PATCH 7/7] docs: add a section for AI agents driving the toolbox 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) Claude-Session: https://claude.ai/code/session_01NiwgsqcQ4JikhFYj4NHEjM --- README.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/README.md b/README.md index d660934..b9e27c1 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,69 @@ The container handles all of it, so the CLIs are just the CLIs. | `toolbox-login --force` | Re-authenticate, e.g. to switch accounts. | | `toolbox-token` | Print the raw token, for scripting. | +## For AI agents + +You can drive this container to reach a GlueOps cluster's `argocd` and `bao`. You +already know those CLIs; this section is only about getting authenticated, which +is the part that isn't obvious. + +The one thing you can't do is authenticate. Login is a device flow: a human opens +a URL and approves with GitHub. Start it, **give the URL to the person you're +working for**, wait for them, then run whatever you were asked. + +**Run detached, not `docker run -it`.** You have no TTY, so an interactive +container gives you nothing to type into and no way to read the URL back out: + +```bash +docker run -d --name toolbox \ + -e TOOLBOX_CAPTAIN_DOMAIN= \ + -e TOOLBOX_BAO_ROLES=reader \ + -v glueops-toolbox:/home/toolbox/.config/glueops \ + ghcr.io/glueops/toolbox:latest sleep 3600 +``` + +Without a TTY the entrypoint skips its automatic login, which is what you want — +you drive it in the next step. + +**Log in in the background and relay the URL.** `toolbox-login` blocks until the +human approves: + +```bash +docker exec toolbox toolbox-login > /tmp/login.log 2>&1 & +sleep 4 +cat /tmp/login.log +``` + +Show them the URL and the code, and stop. Don't poll silently — they can't approve +something they haven't been shown. When they confirm, read the log again: +`Authenticated.` means you're through, and a second line reports whether the +OpenBao login also succeeded. + +`Already authenticated.` with no URL means the cached volume still holds a valid +token. That's success — carry on. Codes expire after five minutes; if one lapses, +just run `toolbox-login` again. + +**Use a login shell for commands.** `docker exec` bypasses the ENTRYPOINT, and the +CLIs are configured in `/etc/toolbox-env.sh`, which login shells source: + +```bash +docker exec toolbox bash -lc 'argocd app list' +docker exec toolbox bash -lc 'bao kv list secret/' +``` + +`docker exec toolbox argocd app list` — without `bash -lc` — will not work. + +**Rules.** + +- **Never print the token.** `toolbox-token` emits a live credential, and you don't + need to read it — the wrappers pass it for you. +- **Default to `TOOLBOX_BAO_ROLES=reader`** unless asked to change something. It's + enforced server-side: writes return `403 permission denied`. +- **Don't mutate anything unasked** — `argocd app sync`, `bao kv put` and friends + act on live infrastructure. +- **Clean up with `docker rm -f toolbox`,** but leave the volume: it holds the + login, so the human isn't asked to approve again next time. + ## Configuration | Variable | Default | |