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/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/.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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..63d8446 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +# 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 +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 + +# 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 \ + && printf '. /etc/toolbox-env.sh\n' >> /home/toolbox/.bashrc \ + && 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..b9e27c1 100644 --- a/README.md +++ b/README.md @@ -1 +1,200 @@ -# 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. | + +## 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 | | +|---|---|---| +| `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` | | +| `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 + +**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. + +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, +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. + +`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,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. + +## Platforms + +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 +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..5d6f815 --- /dev/null +++ b/bin/argocd @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# argocd, with credentials for BOTH the edge and ArgoCD itself. +# +# These are two different headers and each side reads only its own: +# +# ARGOCD_AUTH_TOKEN -> "Token: " ArgoCD reads this +# -H "Authorization" -> "Authorization: Bearer" oauth2-proxy reads this +# +# 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-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/bin/toolbox-login b/bin/toolbox-login new file mode 100755 index 0000000..9ca51e4 --- /dev/null +++ b/bin/toolbox-login @@ -0,0 +1,43 @@ +#!/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. + +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 + + +def main(): + if "--help" in sys.argv or "-h" in sys.argv: + print(__doc__) + return 0 + + force = "--force" in sys.argv + 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) + + # 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/bin/toolbox-proxy b/bin/toolbox-proxy new file mode 100755 index 0000000..dbcc6bc --- /dev/null +++ b/bin/toolbox-proxy @@ -0,0 +1,156 @@ +#!/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): + # 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()) + 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..c40fef5 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,25 @@ +#!/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}" + +# shellcheck source=/dev/null +. /etc/toolbox-env.sh + +PROXY_PORT="${TOOLBOX_PROXY_PORT:-8200}" + +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..737a2c1 --- /dev/null +++ b/lib/toolbox_auth.py @@ -0,0 +1,265 @@ +"""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"] + + +# --------------------------------------------------------------------------- +# 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". +# +# 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,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") + + +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( + f"{bao_addr()}/v1/auth/{bao_auth_path()}/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 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" + } + } +}