diff --git a/Dockerfile b/Dockerfile index 2fee2f1..01a1a22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,16 @@ FROM golang:1.26 AS builder WORKDIR /app -COPY . /app/ +# Cache module downloads when only source changes +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . LABEL org.opencontainers.image.source=https://github.com/donkeyx/cluster-utils-api LABEL maintainer="David Binney " -# Reproducible image build: use locked modules, do not go get -u -RUN make deps build +# Reproducible image build: locked modules only (no go get -u) +RUN make build # no longer using musl dns moved to debian FROM debian:stable-slim diff --git a/Makefile b/Makefile index b1f24f9..636f423 100644 --- a/Makefile +++ b/Makefile @@ -6,8 +6,11 @@ GIT_HASH := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) BUILD_DATE := $(shell date) BUILD_FLAGS := -ldflags="-w -s -X main.Version=$(VERSION) -X main.GitHash=$(GIT_HASH)" +# Pin tool versions (reproducible local + CI) +SWAG_VERSION := v1.16.6 + # Phony targets -.PHONY: all build test clean deps tools update build-all +.PHONY: all build test clean deps tools swagger update build-all # Targets all: clean deps test build-all @@ -23,19 +26,33 @@ test: clean: go clean - find $(BINARY_PATH) -type f ! -name 'keep' -delete + find $(BINARY_PATH) -type f ! -name 'keep' -delete 2>/dev/null || true # Reproducible: download locked modules only (used by Docker) deps: go mod download -# Local tooling (swagger regen, etc.) +# Local tooling โ€” pinned, not @latest tools: - go install github.com/swaggo/swag/cmd/swag@latest + go install github.com/swaggo/swag/cmd/swag@$(SWAG_VERSION) + +# Regen OpenAPI + gin docs from annotations +swagger: tools + swag init -g main.go -o docs -# Explicit dependency upgrades (local / deliberate only) +# Deliberate upgrades of *direct* deps only (never run naked go get -u ./... in Docker/CI) update: - go get -u ./... + go get github.com/gin-gonic/gin@latest + go get github.com/prometheus/client_golang@latest + go get github.com/stretchr/testify@latest + go get github.com/swaggo/files@latest + go get github.com/swaggo/gin-swagger@latest + go get github.com/swaggo/swag@latest + go get go.uber.org/zap@latest + go get golang.org/x/net@latest + go get golang.org/x/crypto@latest + go get golang.org/x/sys@latest + go get golang.org/x/text@latest go mod tidy build-all: diff --git a/README.md b/README.md index d0b5837..b770315 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,34 @@ -# cluster-utils-api +# ๐Ÿด DonkeyX's Cluster Utils API + +``` +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +| ๐Ÿด DonkeyX's Cluster Utils API โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + + //\\ + (/oo\) .----. + (____) | API | + /||\ '----' + //||\\ ๐Ÿ”Œ Probe Mode + ^^ ^^ ^^ + "Kick the tyres on the mesh!" +``` ## description -Simple docker image which will stand up a flexibile api that handles most entrypoints and has all the health variations. This allows me to deploy to a cluster, ecs/eks with any entrypoint or params and it will still run and respond to health checks. Great for testing cluster setup and has endpoints for debugging routing and headers. +HTTP side of the **cluster-utils** toolkit. Where [cluster-utils](https://github.com/donkeyx/cluster-utils) is the **shell box you exec into**, this is the **service you drop into an environment** and hit over HTTP โ€” same donkey energy, different job. + +Throw it into a namespace / ECS task / compose stack and use it to test: + +- **probes** โ€” real kube-style `/startupz`, `/livez`, `/readyz` with fail + delay + flap + runtime control +- **routing & ingress** โ€” hit it through a service, ingress, ALB, mesh; see what actually arrives +- **east-west hops** โ€” north-south into this pod, then `/proxy` out to another svc (headers ride along) +- **headers & identity** โ€” what the proxy rewrote, client IP, host, path (`/headers`, `/debug`, `/echo`) +- **config / params in the env** โ€” dump process env behind auth (`/a/env`) so you can check secrets, configmaps, task defs actually landed +- **bad / slow upstreams** โ€” force status codes and long delays (`/status/503`, `/delay/90`) +- **any entrypoint noise** โ€” binary is also linked as `node` / `npm` so broken charts that call weird commands still come up and serve the api -Default route redirects into the **swagger** docs so you can poke the endpoints from the browser. +Default route dumps you into **swagger** so you can poke things from the browser without memorising paths. | dockerhub: https://hub.docker.com/r/donkeyx/cluster-utils-api @@ -12,93 +36,514 @@ Default route redirects into the **swagger** docs so you can poke the endpoints | github: https://github.com/donkeyx/cluster-utils-api -## Usage +| pair with: https://github.com/donkeyx/cluster-utils (shell / toolkit image) -Most endpoints are open. Anything under `/a/` is authenticated โ€” grab the bearer token from the container logs on startup (it rotates every restart). The app also logs a ready made curl for `/a/env`. +## Auth (how to get the token) -Swagger UI: +Most routes are open on purpose (probes, ingress debug). Anything under **`/a/`** needs a bearer token: -- http://localhost:8080/ (redirects) -- http://localhost:8080/api-docs/index.html +```http +Authorization: Bearer +``` + +| path | why it's locked | +|------|------------------| +| `GET /a/env` | dumps **all env** โ€” secrets, keys, tokens | +| `GET/PUT /a/control/probes` | can fail live (restarts) / ready (drop traffic) | +| `GET/POST /a/proxy` | **SSRF** if open โ€” scan the cluster, hit metadata, pull internal APIs | -Port defaults to `8080`, override with `PORT` if you need to. +See **Security** below for the full split. -### Start container: +### Option 1 โ€” fixed token (easiest for demos) ```bash -docker run -d -p 8080:8080 --name test-api donkeyx/cluster-utils-api:latest +docker run -d -p 8080:8080 -e AUTH_TOKEN=dev --name test-api donkeyx/cluster-utils-api:latest +export TOKEN=dev ``` +Same idea in k8s โ€” set `AUTH_TOKEN` on the container env. + +### Option 2 โ€” random token from logs (default) + +If you **donโ€™t** set `AUTH_TOKEN`, a random token is generated **every process start** and printed in the logs (JSON). + +Look for fields like `token` / `header`, or grep: + +```bash +# local docker +docker logs test-api 2>&1 | grep -E 'token|Bearer|example curl' | head + +# pull just the token value out of the json line (if jq + logs are one json object per line) +docker logs test-api 2>&1 | grep '"token"' | tail -1 | jq -r '.token' + +# kubernetes +kubectl -n default logs deploy/cluster-utils-api --tail=50 | grep -E 'token|Bearer|example curl' +``` + +On startup the app also logs **ready-made curls** (env dump + probe control) with the token already filled in โ€” copy/paste those. + +### Using it + ```bash -# quick route map +export TOKEN=dev # or whatever you pulled from logs + +curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/env | jq +curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/control/probes | jq +``` + +### Swagger UI + +1. Open the UI **on the same host/port you want to call** (that way Try it out just works): + - local: http://localhost:8080/ or `/api-docs/index.html` + - port-forward: http://localhost:8080/api-docs/index.html +2. Click **Authorize** (lock icon) +3. Value: `Bearer ` โ€” word **Bearer**, a space, then the token + Example: `Bearer dev` + Header name is `Authorization`. The UI remembers it in this browser (`PersistAuthorization`). +4. **Try it out** on any route โ€” protected ones under `/a/` need Authorize first. + +**Host / port for Try it out** + +Swagger uses the host you opened the page on (so docker `:8080`, port-forward, or in-cluster ingress all line up without editing the spec). + +If you ever need to point Try it out somewhere else (UI on A, API on B): + +```text +http://localhost:8080/api-docs/index.html?host=cluster-utils-api-svc:8080&scheme=http +http://localhost:8080/api-docs/index.html?host=my-alb.example.com&scheme=https +``` + +`host` = `hostname` or `hostname:port` (no `http://`). `scheme` = `http` or `https`. + +Without a token, `/a/*` returns **401**. + +--- + +## Quick start + +```bash +docker run -d -p 8080:8080 -e AUTH_TOKEN=dev --name test-api donkeyx/cluster-utils-api:latest +export TOKEN=dev + curl -sS localhost:8080/help | jq +curl -sS localhost:8080/version | jq +curl -sS localhost:8080/livez +curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/control/probes | jq +``` + +Port defaults to `8080` (`PORT` to override). + +--- + +## Examples (debugging recipes) + +Assume `TOKEN` is set (see **Auth** above) and the api is on `localhost:8080`. + +### 1. What did the ingress / mesh actually send me? -# health / ping -curl -sS localhost:8080/healthz -curl -sS localhost:8080/ping +```bash +# headers as the pod saw them (X-Forwarded-*, cookies, auth, host, โ€ฆ) +curl -sS -H 'X-Request-Id: demo-1' -H 'X-Forwarded-For: 1.2.3.4' \ + localhost:8080/headers | jq -# debug routing / headers -curl -sS localhost:8080/headers | jq +# fuller dump: hostname, client ip, uri, all headers curl -sS localhost:8080/debug | jq -# env dump (needs the token from logs) -docker logs test-api 2>&1 | head -30 -curl -sS -H "Authorization: Bearer " localhost:8080/a/env | jq +# bounce method + body + query back (good for POST through a gateway) +curl -sS -X POST 'localhost:8080/echo?from=ingress' \ + -H 'Content-Type: application/json' \ + -d '{"hello":"cluster"}' | jq +``` + +### 2. Did my ConfigMap / Secret / task def actually land? + +```bash +curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/env | jq +# or one key: +curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/env | jq '."MY_FEATURE_FLAG"' +``` + +### 3. Readiness: pull the pod out of the Service (no restart) + +```bash +# fail ready โ†’ kube should remove endpoints; process stays up +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"ready":{"mode":"fail"}}' localhost:8080/a/control/probes | jq + +curl -sS -o /dev/null -w 'readyz=%{http_code}\n' localhost:8080/readyz +# in cluster: kubectl get endpoints cluster-utils-api-svc -w + +# put it back +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"ready":{"mode":"ok"}}' localhost:8080/a/control/probes | jq ``` -`/help` looks roughly like: +### 4. Liveness: make kube restart the container + +```bash +# careful โ€” this will restart once failureThreshold is hit +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"live":{"mode":"fail"}}' localhost:8080/a/control/probes | jq + +# watch +# kubectl get pod -l type=api -w +``` + +### 5. Probe timeouts (slow answers) + +Sample manifest uses `timeoutSeconds: 1`. Anything slower counts as a failed probe. + +```bash +# ready answers after 3s โ†’ timeouts with timeoutSeconds: 1 +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"ready":{"mode":"delay","delaySeconds":3}}' localhost:8080/a/control/probes | jq + +time curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/readyz +``` + +### 6. Flapping readiness (in/out of endpoints) + +```bash +# time based: 5s ok, 5s fail, repeat +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"ready":{"mode":"flap","flapSeconds":5}}' localhost:8080/a/control/probes | jq + +# or every 2nd request fails (handy from a loop) +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"ready":{"mode":"flap","flapEvery":2}}' localhost:8080/a/control/probes | jq + +for i in 1 2 3 4; do curl -sS -o /dev/null -w "$i %{http_code}\n" localhost:8080/readyz; done + +# see which half of the flap you're in +curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/control/probes \ + | jq '{ready, readyFlapPhase, uptimeSeconds}' +``` + +### 7. Slow startup / cold start + +```bash +# pretend the app needs 15s to init, then latch "started" +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"startup":{"mode":"ok","bootDelaySeconds":15},"resetStartupLatch":true}' \ + localhost:8080/a/control/probes | jq + +curl -sS -o /dev/null -w 'startupz=%{http_code}\n' localhost:8080/startupz +# wait, then: +curl -sS -o /dev/null -w 'startupz=%{http_code}\n' localhost:8080/startupz +``` + +Deploy-time without control API: + +```bash +docker run -d -p 8080:8080 \ + -e AUTH_TOKEN=dev \ + -e STARTUP_BOOT_DELAY=20 \ + donkeyx/cluster-utils-api:latest +``` + +### 8. Upstream returns 502 / 503 / 418 + +```bash +curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/status/502 +curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/status/503 +curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/status/418 +``` + +### 9. Really slow request + +```bash +# sleep then 200 โ€” default cap 120s (override with MAX_DELAY_SECONDS, hard max 600) +curl -sS localhost:8080/delay/90 +# delayed=90.000s requested=90.000s max=120s + +# or make a *probe* slow (so kube timeoutSeconds trips) +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"ready":{"mode":"delay","delaySeconds":30}}' localhost:8080/a/control/probes | jq +``` + +### 10. East-west hop via north-south (`/a/proxy`) โ€” **auth required** + +Pattern: **ingress โ†’ this api โ†’ another service** (mesh / NetworkPolicy / DNS / header propagation). + +Locked behind bearer on purpose: an open proxy is **SSRF** (anyone could make your pod call internal URLs). + +#### What you get back (default) + +Unless `"raw": true`, the HTTP response from *this* api is always **200 + JSON wrap** (even if upstream was 502). Inside that wrap you get the **full upstream response**: + +| field | what it is | +|-------|------------| +| `response.status` | status code from the other API | +| `response.headers` | **all response headers** from the other API | +| `response.body` | body as a string (capped ~2MB in the wrap) | +| `request.url` / `method` / `headers` / `body` | what we actually sent east-west | +| `meta.durationMs` | hop timing | +| `meta.forwardIncomingHeaders` | whether inbound headers were copied | +| `meta.forwardSensitiveHeaders` | whether Authorization/Cookie were copied | + +Example shape: ```json { - "/": "This can be used to redirect to the swagger docs for more details", - "/a/env": "GET", - "/debug": "GET", - "/headers": "GET", - "/health": "GET", - "/healthz": "GET", - "/ping": "GET", - "/ready": "GET", - "/readyz": "GET" + "request": { + "url": "http://other-api:8080/debug", + "method": "GET", + "headers": { "X-Request-Id": ["demo"], "X-Cu-Proxy-Hop": ["pod-a"] }, + "body": "" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": ["application/json; charset=utf-8"] + }, + "body": "{\"Hostname\":\"other-pod\", ...}" + }, + "meta": { + "durationMs": 12, + "timeoutSeconds": 10, + "forwardIncomingHeaders": true, + "forwardSensitiveHeaders": false, + "proxyHostname": "edge-pod" + } } ``` -### Main endpoints +Handy jq: + +```bash +# just upstream status + headers + body +curl -sS -H "Authorization: Bearer $TOKEN" -H 'X-Request-Id: demo' \ + "$BASE/a/proxy?url=http://other-api:8080/debug" \ + | jq '{status: .response.status, headers: .response.headers, body: .response.body}' +``` + +`"raw": true` โ†’ no wrap; you get the upstream status/headers/body as the real HTTP response (harder to inspect the hop). + +#### Header forwarding + +| inbound headers | default | +|-----------------|---------| +| tracing / custom (`X-Request-Id`, etc.) | **forwarded** | +| hop-by-hop (`Host`, `Connection`, `Content-Length`, โ€ฆ) | stripped | +| **`Authorization` / `Cookie`** | **not** forwarded (so your `/a/*` bearer is not sent to the other svc by accident) | + +To forward credentials east-west on purpose: `"forwardSensitiveHeaders": true`, or set `headers.Authorization` in the JSON body. + +```bash +export BASE=http://localhost:8080 +export TOKEN=dev + +# simple GET hop +curl -sS -H "Authorization: Bearer $TOKEN" \ + -H 'X-Request-Id: demo-ew-1' -H 'X-Trace: abc' \ + "$BASE/a/proxy?url=http://other-api:8080/debug" | jq + +# POST form โ€” full control +curl -sS -X POST "$BASE/a/proxy" \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -H 'X-Request-Id: demo-ew-2' \ + -d '{ + "url": "http://other-api:8080/echo", + "method": "POST", + "body": "{\"ping\":true}", + "headers": {"Content-Type": "application/json"}, + "timeoutSeconds": 15, + "forwardIncomingHeaders": true + }' | jq + +# upstream headers only +curl -sS -H "Authorization: Bearer $TOKEN" \ + "$BASE/a/proxy?url=http://other-api:8080/headers" | jq '.response.headers' + +# slow peer +curl -sS -X POST "$BASE/a/proxy" \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"url":"http://other-api:8080/delay/5","timeoutSeconds":30}' | jq '.meta' +``` + +In-cluster (service DNS) from a port-forwarded edge api: + +```bash +curl -sS -H "Authorization: Bearer $TOKEN" -H 'X-Request-Id: from-laptop' \ + "$BASE/a/proxy?url=http://cluster-utils-api-svc.other-ns.svc.cluster.local:8080/debug" | jq +``` + +Chain multi-hop if you want: A `/a/proxy` โ†’ B `/a/proxy` โ†’ C `/debug` (each hop needs a token for that api). + +### 11. Which build is this pod? + +```bash +curl -sS localhost:8080/version | jq +# {"version":"...","gitHash":"...","hostname":"..."} +``` + +### 12. From inside the cluster (with cluster-utils shell) + +```bash +# port-forward +kubectl -n default port-forward svc/cluster-utils-api-svc 8080:8080 + +# or exec into the toolkit image and curl the service DNS +kubectl exec -it deploy/cluster-utils -- \ + curl -sS http://cluster-utils-api-svc:8080/debug | jq + +# token from api pod logs +TOKEN=$(kubectl -n default logs deploy/cluster-utils-api --tail=100 \ + | grep '"token"' | tail -1 | jq -r '.token') +kubectl exec -it deploy/cluster-utils -- \ + curl -sS -H "Authorization: Bearer $TOKEN" http://cluster-utils-api-svc:8080/a/env | jq +``` + +--- + +## Probes (reference) + +These follow the usual kube split. **Status codes matter more than bodies** โ€” kube only cares 2xx vs not (and timeouts). + +| path | kube role | fail means | aliases | +|------|-----------|------------|---------| +| `GET /startupz` | **startupProbe** | still starting / forced fail | `/startup` | +| `GET /livez` | **livenessProbe** | **restart** the container | `/healthz`, `/health` | +| `GET /readyz` | **readinessProbe** | leave **Service endpoints** (process stays up) | `/ready` | + +### Startup behaves like a real startup endpoint + +1. While `bootDelaySeconds` has not elapsed (from process start, or after a latch reset) โ†’ **503** `starting` +2. First success โ†’ **latches** to started +3. After latch โ†’ always **200** `started` (fast), until process restart or `resetStartupLatch` +4. `mode=fail` โ†’ **503** `startup failed` and **never** latches + +### Modes + +| mode | after optional delay | +|------|----------------------| +| `ok` | 200 | +| `fail` | 503 | +| `delay` | 200 (use `delaySeconds` > probe `timeoutSeconds` for **timeouts**) | +| `flap` | alternates ok/fail โ€” `flapSeconds` (time half-period, default 5) or `flapEvery` (every Nth request) | + +On startup, flap only applies **before** the latch. + +### Seed from env (steady state at deploy) + +| env | default | notes | +|-----|---------|--------| +| `LIVE_MODE` / `HEALTHY_MODE` / `HEALTHY` | ok | `fail` / `flap` / `delay` | +| `LIVE_DELAY` / `HEALTHY_DELAY` | 0 | | +| `LIVE_FLAP_SECONDS` / `LIVE_FLAP_EVERY` | | flap knobs | +| `READY_MODE` / `READY` | ok | | +| `READY_DELAY` | 0 | | +| `READY_FLAP_SECONDS` / `READY_FLAP_EVERY` | | | +| `STARTUP_MODE` / `STARTUP` | ok | | +| `STARTUP_DELAY` | 0 | per-request sleep while not latched | +| `STARTUP_BOOT_DELAY` | 0 | wall clock before first success | +| `STARTUP_FLAP_SECONDS` / `STARTUP_FLAP_EVERY` | | flap before latch | + +Query `?ok=0` still works on live/ready for quick curl hacks โ€” **kube will never send that**, so use env or `/a/control/probes` for real demos. + +### Control API (no redeploy) + +```bash +curl -sS -H "Authorization: Bearer $TOKEN" localhost:8080/a/control/probes | jq + +curl -sS -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{ + "ready": {"mode":"flap","flapSeconds":5}, + "live": {"mode":"ok"}, + "startup": {"mode":"ok","bootDelaySeconds":5}, + "resetStartupLatch": true + }' localhost:8080/a/control/probes | jq +``` + +--- + +## Endpoints | path | notes | |------|--------| | `GET /` | redirect to swagger | | `GET /api-docs/*` | swagger ui | | `GET /help` | json list of routes | -| `GET /health` `/healthz` | returns `OK` | -| `GET /ready` `/readyz` | returns `Ready` | -| `GET /ping` | `PONG` | +| `GET /version` | version + git hash + hostname | +| `GET /startupz` `/startup` | startup probe (latch) | +| `GET /livez` `/healthz` `/health` | liveness | +| `GET /readyz` `/ready` | readiness | +| `GET /ping` | `PONG` (not a kube probe) | | `GET /headers` | request headers | | `GET /debug` | hostname / ip / headers / uri | -| `GET /a/env` | env vars, **bearer auth** | +| `GET /metrics` | prometheus (OpenMetrics): request count/latency/in-flight + Go/process | +| `GET /status/:code` | respond with that http status (100-599) | +| `GET /delay/:seconds` | sleep then 200 (cap `MAX_DELAY_SECONDS`, default 120) | +| `ANY /echo` | bounce method / query / headers / body | +| `GET /a/env` | env vars โ€” **auth** | +| `GET/PUT /a/control/probes` | probe state โ€” **auth** | +| `GET/POST /a/proxy` | east-west hop; full upstream status/headers/body in wrap โ€” **auth** | -### run image in k8 cluster: +### other config -You can run the pod in your cluster with the commands below. This will start a deployment and service but limited to cluster ip. If you want to expose with type loadbalancer you can do it yourself, I don't want you to get a bill from this. +| env | default | what it does | +|-----|---------|----------------| +| `PORT` | `8080` | listen port | +| `AUTH_TOKEN` | random each start | fixed bearer for `/a/*` if set | +| `MAX_DELAY_SECONDS` | `120` (hard max 600) | cap for `/delay`, probe delays, proxy timeouts | -```bash -# apply pod config -kubectl -n default \ - apply -f https://raw.githubusercontent.com/donkeyx/cluster-utils-api/master/k8s-cluster-util-apis.yml -``` +--- + +## Security + +This image is a **cluster debug tool**, not a public SaaS. Treat it like you treat `kubectl` access. + +### Behind bearer (`/a/*`) โ€” keep it that way + +| endpoint | risk if open | +|----------|----------------| +| **`/a/env`** | Full process env โ€” **secrets, API keys, cloud creds**. Correct to lock. | +| **`/a/control/probes`** | Fail liveness โ†’ restarts; fail readiness โ†’ blackhole traffic; flap โ†’ chaos. | +| **`/a/proxy`** | **SSRF**: call any http(s) URL the pod can reach (other namespaces, cloud metadata `169.254.169.254`, internal admin UIs). Response can **exfiltrate** internal data (status + headers + body) to whoever called you. Also why we do **not** auto-forward `Authorization`/`Cookie` east-west. | + +Same auth model as `/a/env` is the right call for `/a/proxy`. If itโ€™s reachable from an ingress without network policy, auth is the main brake. + +### Open on purpose (kube + ingress testing) + +| endpoint | notes | +|----------|--------| +| `/startupz` `/livez` `/readyz` (+ aliases) | **Must** be unauthenticated โ€” kube probes send no bearer | +| `/ping` `/version` `/help` | low sensitivity | +| `/headers` `/debug` `/echo` | can show request headers (including if a client *sent* a secret). Fine for a debug pod; donโ€™t put internet-wide without a gateway auth layer | +| `/status/*` `/delay/*` | abuse = noisy DoS / long requests; cap delay; donโ€™t expose to the open internet | +| `/metrics` | process metrics โ€” usually ok inside the mesh | +| swagger `/api-docs` | documents everything including how to call `/a/*` | + +### Practical guidance + +- Prefer **ClusterIP** + port-forward / exec (as in the sample manifest) for day-to-day use +- If you put it on an ingress, put **auth at the edge** too โ€” donโ€™t rely only on the random token in logs +- Set **`AUTH_TOKEN`** to something you control when automating; rotate if logs are widely readable +- `/a/proxy` is powerful: only point `url` at targets you intend; assume the response body may contain sensitive data from the peer + +--- + +## Run on Kubernetes + +Starts a deployment + ClusterIP service only. If you want a LoadBalancer, wire it yourself โ€” I don't want you to get a bill from this. ```bash +kubectl -n default apply -f \ + https://raw.githubusercontent.com/donkeyx/cluster-utils-api/master/k8s-cluster-util-apis.yml + kubectl get pods,svc -n default -# service is cluster-utils-api-svc on 8080 +# service: cluster-utils-api-svc:8080 ``` -### Now you can use port forwarding to curl your apis inside the cluster +Sample manifest probes: + +- **startupProbe** โ†’ `/startupz` (timeout 1s, period 2s) +- **livenessProbe** โ†’ `/livez` (timeout 1s) +- **readinessProbe** โ†’ `/readyz` (timeout 1s) + +Uncomment the env examples in the yaml to break things on purpose, or flip live via `/a/control/probes` after you grab the token from pod logs (or set `AUTH_TOKEN`). ```bash -# in one window forward the ports to the service kubectl -n default port-forward svc/cluster-utils-api-svc 8080:8080 - -# then curl the service -> pod -curl -sS localhost:8080/debug | jq ``` - -Probes in the manifest hit `/healthz` on container port 8080. diff --git a/docs/docs.go b/docs/docs.go index 097c759..b0efce2 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -15,6 +15,91 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/a/control/probes": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Current live/ready/startup config + startup latch + flap phase + uptime", + "produces": [ + "application/json" + ], + "summary": "Get probe control state", + "operationId": "getProbes", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProbeSnapshot" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Partial update of live/ready/startup. resetStartupLatch re-runs cold start without restarting the process.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Update probe control state", + "operationId": "putProbes", + "parameters": [ + { + "description": "probe update", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ProbeUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProbeSnapshot" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/a/env": { "get": { "security": [ @@ -22,7 +107,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Get the env variables available to the api. This is behind auth under /a/", + "description": "Env dump so you can check secrets/configmaps/task params actually landed. Behind auth under /a/", "produces": [ "application/json" ], @@ -60,9 +145,173 @@ const docTemplate = `{ } } }, + "/a/proxy": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Auth required. Northโ†’south hits this pod; this pod calls url east-west. Default JSON wrap includes upstream status, headers, and body. Open proxy would be SSRF โ€” keep behind bearer.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Proxy / hop to another service (auth)", + "operationId": "proxy", + "parameters": [ + { + "description": "proxy request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ProxyRequest" + } + }, + { + "type": "string", + "description": "absolute url (GET form)", + "name": "url", + "in": "query" + }, + { + "type": "string", + "description": "HTTP method for GET form (default GET)", + "name": "method", + "in": "query" + }, + { + "type": "string", + "default": "Bearer", + "description": "Bearer token", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Auth required. Northโ†’south hits this pod; this pod calls url east-west. Default JSON wrap includes upstream status, headers, and body. Open proxy would be SSRF โ€” keep behind bearer.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Proxy / hop to another service (auth)", + "operationId": "proxy", + "parameters": [ + { + "description": "proxy request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ProxyRequest" + } + }, + { + "type": "string", + "description": "absolute url (GET form)", + "name": "url", + "in": "query" + }, + { + "type": "string", + "description": "HTTP method for GET form (default GET)", + "name": "method", + "in": "query" + }, + { + "type": "string", + "default": "Bearer", + "description": "Bearer token", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/debug": { "get": { - "description": "Get lots of info from running container headers/ips", + "description": "Hostname, client ip, headers, uri โ€” good for routing tests", "produces": [ "application/json" ], @@ -71,6 +320,34 @@ const docTemplate = `{ "responses": { "200": { "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/delay/{seconds}": { + "get": { + "description": "Sleep N seconds then return 200. Cap is MAX_DELAY_SECONDS env (default 120, hard max 600). For probe timeouts prefer LIVE/READY/STARTUP delaySeconds instead", + "produces": [ + "text/plain" + ], + "summary": "Delay then OK", + "operationId": "delay", + "parameters": [ + { + "type": "number", + "description": "seconds to sleep", + "name": "seconds", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "delayed", "schema": { "type": "string" } @@ -78,9 +355,111 @@ const docTemplate = `{ } } }, + "/echo": { + "get": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "patch": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/headers": { "get": { - "description": "Get the headers recieved by the api", + "description": "Headers as seen by the app (handy behind ingress/ALB)", "produces": [ "application/json" ], @@ -90,23 +469,61 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "string" + } } } } } }, - "/health": { + "/help": { "get": { - "description": "Get the health of the api", + "description": "Quick map of useful routes", "produces": [ "application/json" ], - "summary": "Get health", - "operationId": "health", + "summary": "Help", + "operationId": "help", "responses": { "200": { "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/livez": { + "get": { + "description": "Kube liveness. 200 = fine; 503 = restart. Modes: ok|fail|delay|flap. LIVE_* env or /a/control/probes. Aliases: /healthz /health", + "produces": [ + "text/plain" + ], + "summary": "Liveness (livez)", + "operationId": "livez", + "parameters": [ + { + "type": "string", + "description": "one-shot force fail with 0/false (curl only)", + "name": "ok", + "in": "query" + } + ], + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "string" + } + }, + "503": { + "description": "unhealthy", "schema": { "type": "string" } @@ -114,17 +531,17 @@ const docTemplate = `{ } } }, - "/healthz": { + "/metrics": { "get": { - "description": "Get the health of the api", + "description": "Prometheus scrape endpoint (Go + process + HTTP RED metrics)", "produces": [ - "application/json" + "text/plain" ], - "summary": "Get healthz", - "operationId": "healthz", + "summary": "Prometheus metrics", + "operationId": "metrics", "responses": { "200": { - "description": "OK", + "description": "metrics", "schema": { "type": "string" } @@ -134,9 +551,9 @@ const docTemplate = `{ }, "/ping": { "get": { - "description": "Get the readyness of the api", + "description": "Simple alive check (not a kube probe โ€” use /livez for that)", "produces": [ - "application/json" + "text/plain" ], "summary": "Get ping", "operationId": "ping", @@ -150,17 +567,31 @@ const docTemplate = `{ } } }, - "/ready": { + "/readyz": { "get": { - "description": "Get the readyness of the api", + "description": "Kube readiness. 200 = take traffic; 503 = leave Service endpoints. Modes: ok|fail|delay|flap. Alias: /ready", "produces": [ - "application/json" + "text/plain" + ], + "summary": "Readiness (readyz)", + "operationId": "readyz", + "parameters": [ + { + "type": "string", + "description": "one-shot force fail (curl only)", + "name": "ok", + "in": "query" + } ], - "summary": "Get ready", - "operationId": "ready", "responses": { "200": { - "description": "OK", + "description": "ready", + "schema": { + "type": "string" + } + }, + "503": { + "description": "not ready", "schema": { "type": "string" } @@ -168,28 +599,201 @@ const docTemplate = `{ } } }, - "/readyz": { + "/startupz": { + "get": { + "description": "Cold start latch: 503 until bootDelay, then sticky 200. mode=fail never latches. mode=flap oscillates until a success latches (or forever if you only hit fail phases โ€” use flap carefully here).", + "produces": [ + "text/plain" + ], + "summary": "Startup (startupz)", + "operationId": "startupz", + "responses": { + "200": { + "description": "started", + "schema": { + "type": "string" + } + }, + "503": { + "description": "starting", + "schema": { + "type": "string" + } + } + } + } + }, + "/status/{code}": { "get": { - "description": "Get the readyness of the api", + "description": "Respond with whatever http status you pass (100-599). Great for ingress/retry testing", + "produces": [ + "text/plain" + ], + "summary": "Fixed status code", + "operationId": "status", + "parameters": [ + { + "type": "integer", + "description": "HTTP status code", + "name": "code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "status body", + "schema": { + "type": "string" + } + } + } + } + }, + "/version": { + "get": { + "description": "What binary is running in this env", "produces": [ "application/json" ], - "summary": "Get readyz", - "operationId": "readyz", + "summary": "Version / build info", + "operationId": "version", "responses": { "200": { "description": "OK", "schema": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "string" + } } } } } } }, + "definitions": { + "handlers.ProbeConfig": { + "type": "object", + "properties": { + "bootDelaySeconds": { + "description": "Startup only: wall-clock seconds from process start before a success is allowed.", + "type": "number" + }, + "delaySeconds": { + "description": "sleep before answering (0โ€“30)", + "type": "number" + }, + "flapEvery": { + "type": "integer" + }, + "flapSeconds": { + "description": "Flap (mode=flap):\n flapSeconds โ€” wall-clock half-period: ok for N sec, fail for N sec, repeat (default 5).\n flapEvery โ€” if \u003e 0, every Nth request fails instead of using the clock (handy for tests).", + "type": "number" + }, + "mode": { + "description": "ok | fail | delay | flap", + "type": "string" + } + } + }, + "handlers.ProbeSnapshot": { + "type": "object", + "properties": { + "live": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "liveFlapPhase": { + "description": "Which half of a time-based flap we are in right now (ok|fail|n/a).", + "type": "string" + }, + "ready": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "readyFlapPhase": { + "type": "string" + }, + "startup": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "startupFlapPhase": { + "type": "string" + }, + "startupLatched": { + "type": "boolean" + }, + "startupWouldPass": { + "type": "boolean" + }, + "uptimeSeconds": { + "type": "number" + } + } + }, + "handlers.ProbeUpdate": { + "type": "object", + "properties": { + "live": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "ready": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "resetStartupLatch": { + "type": "boolean" + }, + "startup": { + "$ref": "#/definitions/handlers.ProbeConfig" + } + } + }, + "handlers.ProxyRequest": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "body": { + "description": "Optional body (string; use for JSON text, form, etc.)", + "type": "string" + }, + "forwardIncomingHeaders": { + "description": "When true (default), copy inbound request headers onto the outbound call\n(minus hop-by-hop). Tracing headers like X-Request-Id ride along.", + "type": "boolean" + }, + "forwardSensitiveHeaders": { + "description": "When true, also forward Authorization / Cookie from the inbound request.\nDefault false so your bearer token for *this* api is not sent east-west by accident.", + "type": "boolean" + }, + "headers": { + "description": "Extra headers to set/override on the outbound request", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "method": { + "description": "HTTP method (default GET)", + "type": "string" + }, + "raw": { + "description": "When true, return the upstream body as the raw HTTP response (status + headers from upstream).\nDefault false โ†’ JSON wrap with request/response/meta (includes upstream headers + body).", + "type": "boolean" + }, + "timeoutSeconds": { + "description": "Timeout for the outbound call (default 10; capped by MAX_DELAY_SECONDS)", + "type": "number" + }, + "url": { + "description": "Absolute URL to call, e.g. http://other-api:8080/debug", + "type": "string" + } + } + } + }, "securityDefinitions": { "BearerAuth": { - "description": "Type \"Bearer\" followed by a space and the token from the app logs on startup.", + "description": "Paste: Bearer \u003ctoken\u003e (token from container logs, or AUTH_TOKEN env). Example: Bearer dev", "type": "apiKey", "name": "Authorization", "in": "header" @@ -200,11 +804,11 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ Version: "2.0", - Host: "localhost:8080", + Host: "", BasePath: "/", Schemes: []string{}, Title: "Cluster Util API", - Description: "This is a util api which lots of endpoints making it easy to test routing/ingress/egress", + Description: "Drop-in HTTP util for testing probes, routing, headers, env/params and more in a cluster. Swagger \"Try it out\" uses the host you opened the UI on (or override with ?host=host:port&scheme=http on /api-docs/index.html). Authorize with Bearer token from logs or AUTH_TOKEN.", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/docs/swagger.json b/docs/swagger.json index 16e847e..047f92f 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1,14 +1,98 @@ { "swagger": "2.0", "info": { - "description": "This is a util api which lots of endpoints making it easy to test routing/ingress/egress", + "description": "Drop-in HTTP util for testing probes, routing, headers, env/params and more in a cluster. Swagger \"Try it out\" uses the host you opened the UI on (or override with ?host=host:port\u0026scheme=http on /api-docs/index.html). Authorize with Bearer token from logs or AUTH_TOKEN.", "title": "Cluster Util API", "contact": {}, "version": "2.0" }, - "host": "localhost:8080", "basePath": "/", "paths": { + "/a/control/probes": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Current live/ready/startup config + startup latch + flap phase + uptime", + "produces": [ + "application/json" + ], + "summary": "Get probe control state", + "operationId": "getProbes", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProbeSnapshot" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Partial update of live/ready/startup. resetStartupLatch re-runs cold start without restarting the process.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Update probe control state", + "operationId": "putProbes", + "parameters": [ + { + "description": "probe update", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ProbeUpdate" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProbeSnapshot" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/a/env": { "get": { "security": [ @@ -16,7 +100,7 @@ "BearerAuth": [] } ], - "description": "Get the env variables available to the api. This is behind auth under /a/", + "description": "Env dump so you can check secrets/configmaps/task params actually landed. Behind auth under /a/", "produces": [ "application/json" ], @@ -54,9 +138,173 @@ } } }, + "/a/proxy": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Auth required. Northโ†’south hits this pod; this pod calls url east-west. Default JSON wrap includes upstream status, headers, and body. Open proxy would be SSRF โ€” keep behind bearer.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Proxy / hop to another service (auth)", + "operationId": "proxy", + "parameters": [ + { + "description": "proxy request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ProxyRequest" + } + }, + { + "type": "string", + "description": "absolute url (GET form)", + "name": "url", + "in": "query" + }, + { + "type": "string", + "description": "HTTP method for GET form (default GET)", + "name": "method", + "in": "query" + }, + { + "type": "string", + "default": "Bearer", + "description": "Bearer token", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Auth required. Northโ†’south hits this pod; this pod calls url east-west. Default JSON wrap includes upstream status, headers, and body. Open proxy would be SSRF โ€” keep behind bearer.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Proxy / hop to another service (auth)", + "operationId": "proxy", + "parameters": [ + { + "description": "proxy request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ProxyRequest" + } + }, + { + "type": "string", + "description": "absolute url (GET form)", + "name": "url", + "in": "query" + }, + { + "type": "string", + "description": "HTTP method for GET form (default GET)", + "name": "method", + "in": "query" + }, + { + "type": "string", + "default": "Bearer", + "description": "Bearer token", + "name": "Authorization", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/debug": { "get": { - "description": "Get lots of info from running container headers/ips", + "description": "Hostname, client ip, headers, uri โ€” good for routing tests", "produces": [ "application/json" ], @@ -65,6 +313,34 @@ "responses": { "200": { "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/delay/{seconds}": { + "get": { + "description": "Sleep N seconds then return 200. Cap is MAX_DELAY_SECONDS env (default 120, hard max 600). For probe timeouts prefer LIVE/READY/STARTUP delaySeconds instead", + "produces": [ + "text/plain" + ], + "summary": "Delay then OK", + "operationId": "delay", + "parameters": [ + { + "type": "number", + "description": "seconds to sleep", + "name": "seconds", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "delayed", "schema": { "type": "string" } @@ -72,9 +348,111 @@ } } }, + "/echo": { + "get": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "patch": { + "description": "Bounce method, path, query, headers and body back as json", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "summary": "Echo request", + "operationId": "echo", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/headers": { "get": { - "description": "Get the headers recieved by the api", + "description": "Headers as seen by the app (handy behind ingress/ALB)", "produces": [ "application/json" ], @@ -84,23 +462,61 @@ "200": { "description": "OK", "schema": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "string" + } } } } } }, - "/health": { + "/help": { "get": { - "description": "Get the health of the api", + "description": "Quick map of useful routes", "produces": [ "application/json" ], - "summary": "Get health", - "operationId": "health", + "summary": "Help", + "operationId": "help", "responses": { "200": { "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/livez": { + "get": { + "description": "Kube liveness. 200 = fine; 503 = restart. Modes: ok|fail|delay|flap. LIVE_* env or /a/control/probes. Aliases: /healthz /health", + "produces": [ + "text/plain" + ], + "summary": "Liveness (livez)", + "operationId": "livez", + "parameters": [ + { + "type": "string", + "description": "one-shot force fail with 0/false (curl only)", + "name": "ok", + "in": "query" + } + ], + "responses": { + "200": { + "description": "ok", + "schema": { + "type": "string" + } + }, + "503": { + "description": "unhealthy", "schema": { "type": "string" } @@ -108,17 +524,17 @@ } } }, - "/healthz": { + "/metrics": { "get": { - "description": "Get the health of the api", + "description": "Prometheus scrape endpoint (Go + process + HTTP RED metrics)", "produces": [ - "application/json" + "text/plain" ], - "summary": "Get healthz", - "operationId": "healthz", + "summary": "Prometheus metrics", + "operationId": "metrics", "responses": { "200": { - "description": "OK", + "description": "metrics", "schema": { "type": "string" } @@ -128,9 +544,9 @@ }, "/ping": { "get": { - "description": "Get the readyness of the api", + "description": "Simple alive check (not a kube probe โ€” use /livez for that)", "produces": [ - "application/json" + "text/plain" ], "summary": "Get ping", "operationId": "ping", @@ -144,17 +560,31 @@ } } }, - "/ready": { + "/readyz": { "get": { - "description": "Get the readyness of the api", + "description": "Kube readiness. 200 = take traffic; 503 = leave Service endpoints. Modes: ok|fail|delay|flap. Alias: /ready", "produces": [ - "application/json" + "text/plain" + ], + "summary": "Readiness (readyz)", + "operationId": "readyz", + "parameters": [ + { + "type": "string", + "description": "one-shot force fail (curl only)", + "name": "ok", + "in": "query" + } ], - "summary": "Get ready", - "operationId": "ready", "responses": { "200": { - "description": "OK", + "description": "ready", + "schema": { + "type": "string" + } + }, + "503": { + "description": "not ready", "schema": { "type": "string" } @@ -162,28 +592,201 @@ } } }, - "/readyz": { + "/startupz": { + "get": { + "description": "Cold start latch: 503 until bootDelay, then sticky 200. mode=fail never latches. mode=flap oscillates until a success latches (or forever if you only hit fail phases โ€” use flap carefully here).", + "produces": [ + "text/plain" + ], + "summary": "Startup (startupz)", + "operationId": "startupz", + "responses": { + "200": { + "description": "started", + "schema": { + "type": "string" + } + }, + "503": { + "description": "starting", + "schema": { + "type": "string" + } + } + } + } + }, + "/status/{code}": { "get": { - "description": "Get the readyness of the api", + "description": "Respond with whatever http status you pass (100-599). Great for ingress/retry testing", + "produces": [ + "text/plain" + ], + "summary": "Fixed status code", + "operationId": "status", + "parameters": [ + { + "type": "integer", + "description": "HTTP status code", + "name": "code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "status body", + "schema": { + "type": "string" + } + } + } + } + }, + "/version": { + "get": { + "description": "What binary is running in this env", "produces": [ "application/json" ], - "summary": "Get readyz", - "operationId": "readyz", + "summary": "Version / build info", + "operationId": "version", "responses": { "200": { "description": "OK", "schema": { - "type": "string" + "type": "object", + "additionalProperties": { + "type": "string" + } } } } } } }, + "definitions": { + "handlers.ProbeConfig": { + "type": "object", + "properties": { + "bootDelaySeconds": { + "description": "Startup only: wall-clock seconds from process start before a success is allowed.", + "type": "number" + }, + "delaySeconds": { + "description": "sleep before answering (0โ€“30)", + "type": "number" + }, + "flapEvery": { + "type": "integer" + }, + "flapSeconds": { + "description": "Flap (mode=flap):\n flapSeconds โ€” wall-clock half-period: ok for N sec, fail for N sec, repeat (default 5).\n flapEvery โ€” if \u003e 0, every Nth request fails instead of using the clock (handy for tests).", + "type": "number" + }, + "mode": { + "description": "ok | fail | delay | flap", + "type": "string" + } + } + }, + "handlers.ProbeSnapshot": { + "type": "object", + "properties": { + "live": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "liveFlapPhase": { + "description": "Which half of a time-based flap we are in right now (ok|fail|n/a).", + "type": "string" + }, + "ready": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "readyFlapPhase": { + "type": "string" + }, + "startup": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "startupFlapPhase": { + "type": "string" + }, + "startupLatched": { + "type": "boolean" + }, + "startupWouldPass": { + "type": "boolean" + }, + "uptimeSeconds": { + "type": "number" + } + } + }, + "handlers.ProbeUpdate": { + "type": "object", + "properties": { + "live": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "ready": { + "$ref": "#/definitions/handlers.ProbeConfig" + }, + "resetStartupLatch": { + "type": "boolean" + }, + "startup": { + "$ref": "#/definitions/handlers.ProbeConfig" + } + } + }, + "handlers.ProxyRequest": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "body": { + "description": "Optional body (string; use for JSON text, form, etc.)", + "type": "string" + }, + "forwardIncomingHeaders": { + "description": "When true (default), copy inbound request headers onto the outbound call\n(minus hop-by-hop). Tracing headers like X-Request-Id ride along.", + "type": "boolean" + }, + "forwardSensitiveHeaders": { + "description": "When true, also forward Authorization / Cookie from the inbound request.\nDefault false so your bearer token for *this* api is not sent east-west by accident.", + "type": "boolean" + }, + "headers": { + "description": "Extra headers to set/override on the outbound request", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "method": { + "description": "HTTP method (default GET)", + "type": "string" + }, + "raw": { + "description": "When true, return the upstream body as the raw HTTP response (status + headers from upstream).\nDefault false โ†’ JSON wrap with request/response/meta (includes upstream headers + body).", + "type": "boolean" + }, + "timeoutSeconds": { + "description": "Timeout for the outbound call (default 10; capped by MAX_DELAY_SECONDS)", + "type": "number" + }, + "url": { + "description": "Absolute URL to call, e.g. http://other-api:8080/debug", + "type": "string" + } + } + } + }, "securityDefinitions": { "BearerAuth": { - "description": "Type \"Bearer\" followed by a space and the token from the app logs on startup.", + "description": "Paste: Bearer \u003ctoken\u003e (token from container logs, or AUTH_TOKEN env). Example: Bearer dev", "type": "apiKey", "name": "Authorization", "in": "header" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d0efd3f..58e80d0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,15 +1,165 @@ basePath: / -host: localhost:8080 +definitions: + handlers.ProbeConfig: + properties: + bootDelaySeconds: + description: 'Startup only: wall-clock seconds from process start before a + success is allowed.' + type: number + delaySeconds: + description: sleep before answering (0โ€“30) + type: number + flapEvery: + type: integer + flapSeconds: + description: |- + Flap (mode=flap): + flapSeconds โ€” wall-clock half-period: ok for N sec, fail for N sec, repeat (default 5). + flapEvery โ€” if > 0, every Nth request fails instead of using the clock (handy for tests). + type: number + mode: + description: ok | fail | delay | flap + type: string + type: object + handlers.ProbeSnapshot: + properties: + live: + $ref: '#/definitions/handlers.ProbeConfig' + liveFlapPhase: + description: Which half of a time-based flap we are in right now (ok|fail|n/a). + type: string + ready: + $ref: '#/definitions/handlers.ProbeConfig' + readyFlapPhase: + type: string + startup: + $ref: '#/definitions/handlers.ProbeConfig' + startupFlapPhase: + type: string + startupLatched: + type: boolean + startupWouldPass: + type: boolean + uptimeSeconds: + type: number + type: object + handlers.ProbeUpdate: + properties: + live: + $ref: '#/definitions/handlers.ProbeConfig' + ready: + $ref: '#/definitions/handlers.ProbeConfig' + resetStartupLatch: + type: boolean + startup: + $ref: '#/definitions/handlers.ProbeConfig' + type: object + handlers.ProxyRequest: + properties: + body: + description: Optional body (string; use for JSON text, form, etc.) + type: string + forwardIncomingHeaders: + description: |- + When true (default), copy inbound request headers onto the outbound call + (minus hop-by-hop). Tracing headers like X-Request-Id ride along. + type: boolean + forwardSensitiveHeaders: + description: |- + When true, also forward Authorization / Cookie from the inbound request. + Default false so your bearer token for *this* api is not sent east-west by accident. + type: boolean + headers: + additionalProperties: + type: string + description: Extra headers to set/override on the outbound request + type: object + method: + description: HTTP method (default GET) + type: string + raw: + description: |- + When true, return the upstream body as the raw HTTP response (status + headers from upstream). + Default false โ†’ JSON wrap with request/response/meta (includes upstream headers + body). + type: boolean + timeoutSeconds: + description: Timeout for the outbound call (default 10; capped by MAX_DELAY_SECONDS) + type: number + url: + description: Absolute URL to call, e.g. http://other-api:8080/debug + type: string + required: + - url + type: object info: contact: {} - description: This is a util api which lots of endpoints making it easy to test routing/ingress/egress + description: Drop-in HTTP util for testing probes, routing, headers, env/params + and more in a cluster. Swagger "Try it out" uses the host you opened the UI on + (or override with ?host=host:port&scheme=http on /api-docs/index.html). Authorize + with Bearer token from logs or AUTH_TOKEN. title: Cluster Util API version: "2.0" paths: + /a/control/probes: + get: + description: Current live/ready/startup config + startup latch + flap phase + + uptime + operationId: getProbes + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ProbeSnapshot' + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get probe control state + put: + consumes: + - application/json + description: Partial update of live/ready/startup. resetStartupLatch re-runs + cold start without restarting the process. + operationId: putProbes + parameters: + - description: probe update + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.ProbeUpdate' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ProbeSnapshot' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update probe control state /a/env: get: - description: Get the env variables available to the api. This is behind auth - under /a/ + description: Env dump so you can check secrets/configmaps/task params actually + landed. Behind auth under /a/ operationId: env parameters: - default: Bearer @@ -36,9 +186,122 @@ paths: security: - BearerAuth: [] summary: Get environment variables + /a/proxy: + get: + consumes: + - application/json + description: Auth required. Northโ†’south hits this pod; this pod calls url east-west. + Default JSON wrap includes upstream status, headers, and body. Open proxy + would be SSRF โ€” keep behind bearer. + operationId: proxy + parameters: + - description: proxy request + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.ProxyRequest' + - description: absolute url (GET form) + in: query + name: url + type: string + - description: HTTP method for GET form (default GET) + in: query + name: method + type: string + - default: Bearer + description: Bearer token + in: header + name: Authorization + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "502": + description: Bad Gateway + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Proxy / hop to another service (auth) + post: + consumes: + - application/json + description: Auth required. Northโ†’south hits this pod; this pod calls url east-west. + Default JSON wrap includes upstream status, headers, and body. Open proxy + would be SSRF โ€” keep behind bearer. + operationId: proxy + parameters: + - description: proxy request + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.ProxyRequest' + - description: absolute url (GET form) + in: query + name: url + type: string + - description: HTTP method for GET form (default GET) + in: query + name: method + type: string + - default: Bearer + description: Bearer token + in: header + name: Authorization + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "502": + description: Bad Gateway + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: Proxy / hop to another service (auth) /debug: get: - description: Get lots of info from running container headers/ips + description: Hostname, client ip, headers, uri โ€” good for routing tests operationId: debug produces: - application/json @@ -46,11 +309,103 @@ paths: "200": description: OK schema: - type: string + additionalProperties: true + type: object summary: Debug + /delay/{seconds}: + get: + description: Sleep N seconds then return 200. Cap is MAX_DELAY_SECONDS env (default + 120, hard max 600). For probe timeouts prefer LIVE/READY/STARTUP delaySeconds + instead + operationId: delay + parameters: + - description: seconds to sleep + in: path + name: seconds + required: true + type: number + produces: + - text/plain + responses: + "200": + description: delayed + schema: + type: string + summary: Delay then OK + /echo: + delete: + consumes: + - text/plain + description: Bounce method, path, query, headers and body back as json + operationId: echo + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: Echo request + get: + consumes: + - text/plain + description: Bounce method, path, query, headers and body back as json + operationId: echo + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: Echo request + patch: + consumes: + - text/plain + description: Bounce method, path, query, headers and body back as json + operationId: echo + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: Echo request + post: + consumes: + - text/plain + description: Bounce method, path, query, headers and body back as json + operationId: echo + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: Echo request + put: + consumes: + - text/plain + description: Bounce method, path, query, headers and body back as json + operationId: echo + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: Echo request /headers: get: - description: Get the headers recieved by the api + description: Headers as seen by the app (handy behind ingress/ALB) operationId: headers produces: - application/json @@ -58,72 +413,147 @@ paths: "200": description: OK schema: - type: string + additionalProperties: + type: string + type: object summary: Get headers - /health: + /help: get: - description: Get the health of the api - operationId: health + description: Quick map of useful routes + operationId: help produces: - application/json responses: "200": description: OK + schema: + additionalProperties: + type: string + type: object + summary: Help + /livez: + get: + description: 'Kube liveness. 200 = fine; 503 = restart. Modes: ok|fail|delay|flap. + LIVE_* env or /a/control/probes. Aliases: /healthz /health' + operationId: livez + parameters: + - description: one-shot force fail with 0/false (curl only) + in: query + name: ok + type: string + produces: + - text/plain + responses: + "200": + description: ok + schema: + type: string + "503": + description: unhealthy schema: type: string - summary: Get health - /healthz: + summary: Liveness (livez) + /metrics: get: - description: Get the health of the api - operationId: healthz + description: Prometheus scrape endpoint (Go + process + HTTP RED metrics) + operationId: metrics produces: - - application/json + - text/plain responses: "200": - description: OK + description: metrics schema: type: string - summary: Get healthz + summary: Prometheus metrics /ping: get: - description: Get the readyness of the api + description: Simple alive check (not a kube probe โ€” use /livez for that) operationId: ping produces: - - application/json + - text/plain responses: "200": description: PONG schema: type: string summary: Get ping - /ready: + /readyz: get: - description: Get the readyness of the api - operationId: ready + description: 'Kube readiness. 200 = take traffic; 503 = leave Service endpoints. + Modes: ok|fail|delay|flap. Alias: /ready' + operationId: readyz + parameters: + - description: one-shot force fail (curl only) + in: query + name: ok + type: string produces: - - application/json + - text/plain responses: "200": - description: OK + description: ready schema: type: string - summary: Get ready - /readyz: + "503": + description: not ready + schema: + type: string + summary: Readiness (readyz) + /startupz: get: - description: Get the readyness of the api - operationId: readyz + description: 'Cold start latch: 503 until bootDelay, then sticky 200. mode=fail + never latches. mode=flap oscillates until a success latches (or forever if + you only hit fail phases โ€” use flap carefully here).' + operationId: startupz + produces: + - text/plain + responses: + "200": + description: started + schema: + type: string + "503": + description: starting + schema: + type: string + summary: Startup (startupz) + /status/{code}: + get: + description: Respond with whatever http status you pass (100-599). Great for + ingress/retry testing + operationId: status + parameters: + - description: HTTP status code + in: path + name: code + required: true + type: integer + produces: + - text/plain + responses: + "200": + description: status body + schema: + type: string + summary: Fixed status code + /version: + get: + description: What binary is running in this env + operationId: version produces: - application/json responses: "200": description: OK schema: - type: string - summary: Get readyz + additionalProperties: + type: string + type: object + summary: Version / build info securityDefinitions: BearerAuth: - description: Type "Bearer" followed by a space and the token from the app logs - on startup. + description: 'Paste: Bearer (token from container logs, or AUTH_TOKEN + env). Example: Bearer dev' in: header name: Authorization type: apiKey diff --git a/go.mod b/go.mod index 5442bc8..14dd787 100644 --- a/go.mod +++ b/go.mod @@ -1,64 +1,68 @@ -module cu-api +module github.com/donkeyx/cluster-utils-api go 1.26 require ( - github.com/gin-gonic/gin v1.10.0 - github.com/prometheus/client_golang v1.19.1 - github.com/stretchr/testify v1.9.0 + github.com/gin-gonic/gin v1.12.0 + github.com/prometheus/client_golang v1.24.1 + github.com/stretchr/testify v1.11.1 github.com/swaggo/files v1.0.1 - github.com/swaggo/gin-swagger v1.6.0 - github.com/swaggo/swag v1.16.3 - go.uber.org/zap v1.27.0 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 + go.uber.org/zap v1.28.0 ) require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bytedance/sonic v1.11.9 // indirect - github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect - github.com/chenzhuoyu/iasm v0.9.1 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect - github.com/cloudwego/iasm v0.2.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.4 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/spec v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.22.0 // indirect - github.com/goccy/go-json v0.10.3 // indirect - github.com/golang/protobuf v1.5.4 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.8 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.7.6 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.55.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect - github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.12 // indirect - go.uber.org/multierr v1.11.0 // indirect - golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.25.0 // indirect - golang.org/x/net v0.27.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect - golang.org/x/tools v0.23.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ab70b70..83d9ca8 100644 --- a/go.sum +++ b/go.sum @@ -1,130 +1,83 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= -github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= -github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= -github.com/bytedance/sonic v1.10.1 h1:7a1wuFXL1cMy7a3f7/VFcEtriuXQnUBhtoVfOZiaysc= -github.com/bytedance/sonic v1.10.1/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= -github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg= -github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= -github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= -github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= -github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= -github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= -github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo= -github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= -github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0= -github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= -github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= -github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= -github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= -github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= -github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= -github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= -github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= -github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.20.0 h1:ESKJdU9ASRfaPNOPRx12IUyA1vn3R9GiE3KYD14BXdQ= -github.com/go-openapi/jsonpointer v0.20.0/go.mod h1:6PGzBjjIIumbLYysB73Klnms1mwnU4G3YHOECG3CedA= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/spec v0.20.9 h1:xnlYNQAwKd2VQRRfwTEI0DcK+2cbuvI/0c7jx3gA8/8= -github.com/go-openapi/spec v0.20.9/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= -github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= -github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= -github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.15.4 h1:zMXza4EpOdooxPel5xDqXEdXG5r+WggpvnAKMsalBjs= -github.com/go-playground/validator/v10 v10.15.4/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= -github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= -github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= -github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= -github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= -github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -133,134 +86,109 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= -github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= -github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= -github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= -github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= -github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= -github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= -github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= -github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= -github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= -github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= -github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= -github.com/swaggo/swag v1.16.2 h1:28Pp+8DkQoV+HLzLx8RGJZXNGKbFqnuvSbAAtoxiY04= -github.com/swaggo/swag v1.16.2/go.mod h1:6YzXnDcpr0767iOejs318CwYkCQqyGer6BizOg03f+E= -github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= -github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= -go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= -go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y= -golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= -golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= -golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -273,5 +201,3 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/handlers/handlers.go b/handlers/handlers.go index 9ca2442..1f7153f 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -1,139 +1,102 @@ package handlers import ( - "encoding/json" + "io" "net" "net/http" "os" + "strconv" "strings" + "time" "github.com/gin-gonic/gin" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" ) +// Build info injected from main (ldflags). var ( - requestsTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "http_requests_total", - Help: "Total number of HTTP requests.", - }, - []string{"method", "path", "status"}, - ) + AppVersion = "dev" + AppGitHash = "unknown" ) -func init() { - prometheus.MustRegister(requestsTotal) -} - -func PrometheusMetricsHandler() gin.HandlerFunc { - return func(c *gin.Context) { - handler := promhttp.Handler() - handler.ServeHTTP(c.Writer, c.Request) +// SetBuildInfo lets main push version/git hash from ldflags. +func SetBuildInfo(version, gitHash string) { + if version != "" { + AppVersion = version + } + if gitHash != "" { + AppGitHash = gitHash } } -// @Summary Get healthz -// @Description Get the health of the api -// @ID healthz +// @Summary Help +// @Description Quick map of useful routes +// @ID help // @Produce json -// @Success 200 {string} string "OK" -// @Router /healthz [get] -// HealthHandler handles GET requests to /health endpoint and returns a 200 status code with "OK" message. -func HealthzHandler(c *gin.Context) { - requestsTotal.WithLabelValues("GET", "/healthz", "200").Inc() - HealthHandler(c) -} - -// HelpHandler handles GET requests to /help endpoint and returns a list of available routes. +// @Success 200 {object} map[string]string +// @Router /help [get] func HelpHandler(c *gin.Context) { - routes := make(map[string]string) - routes["/healthz"] = "GET" - routes["/health"] = "GET" - routes["/readyz"] = "GET" - routes["/ready"] = "GET" - routes["/ping"] = "GET" - routes["/headers"] = "GET" - routes["/a/env"] = "GET" - routes["/debug"] = "GET" - routes["/"] = "This can be used to redirect to the swagger docs for more details" - - c.JSON(http.StatusOK, routes) + c.JSON(http.StatusOK, map[string]string{ + "/": "redirect to swagger docs", + "/api-docs/*": "swagger ui", + "/help": "GET this list", + "/version": "GET build version / git hash", + "/livez|/healthz|/health": "GET liveness (LIVE_MODE / LIVE_DELAY)", + "/readyz|/ready": "GET readiness (READY_MODE / READY_DELAY)", + "/startupz|/startup": "GET startup latch (STARTUP_* / boot delay)", + "/ping": "GET PONG", + "/headers": "GET request headers", + "/debug": "GET hostname / ip / headers / uri", + "/metrics": "GET prometheus metrics", + "/status/:code": "GET respond with that http status", + "/delay/:seconds": "GET sleep then 200 (MAX_DELAY_SECONDS cap)", + "/echo": "GET/POST echo method, headers, query, body", + "/a/env": "GET env vars (bearer auth)", + "/a/control/probes": "GET/PUT probe state (bearer auth)", + "/a/proxy": "GET/POST east-west hop (bearer auth; SSRF-sensitive)", + }) } -// @Summary Get health -// @Description Get the health of the api -// @ID health +// @Summary Version / build info +// @Description What binary is running in this env +// @ID version // @Produce json -// @Success 200 {string} string "OK" -// @Router /health [get] -// HealthHandler handles GET requests to /health endpoint and returns a 200 status code with "OK" message. -func HealthHandler(c *gin.Context) { - requestsTotal.WithLabelValues("GET", "/health", "200").Inc() - c.String(http.StatusOK, "OK") -} - -// @Summary Get readyz -// @Description Get the readyness of the api -// @ID readyz -// @Produce json -// @Success 200 {string} string "OK" -// @Router /readyz [get] -func ReadyzHandler(c *gin.Context) { - ReadyHandler(c) -} - -// @Summary Get ready -// @Description Get the readyness of the api -// @ID ready -// @Produce json -// @Success 200 {string} string "OK" -// @Router /ready [get] -func ReadyHandler(c *gin.Context) { - isReady := true // not sure what i will do here? - - if isReady { - c.String(http.StatusOK, "Ready") - } else { - c.String(http.StatusServiceUnavailable, "Not Ready") - } +// @Success 200 {object} map[string]string +// @Router /version [get] +func VersionHandler(c *gin.Context) { + hostname, _ := os.Hostname() + c.JSON(http.StatusOK, gin.H{ + "version": AppVersion, + "gitHash": AppGitHash, + "hostname": hostname, + }) } // @Summary Get ping -// @Description Get the readyness of the api +// @Description Simple alive check (not a kube probe โ€” use /livez for that) // @ID ping -// @Produce json +// @Produce plain // @Success 200 {string} string "PONG" // @Router /ping [get] -// PingHandler handles the ping endpoint and returns a "PONG" response. func PingHandler(c *gin.Context) { c.String(http.StatusOK, "PONG") } // @Summary Get headers -// @Description Get the headers recieved by the api +// @Description Headers as seen by the app (handy behind ingress/ALB) // @ID headers // @Produce json -// @Success 200 {string} string "OK" +// @Success 200 {object} map[string]string // @Router /headers [get] func HeadersHandler(c *gin.Context) { headers := make(map[string]string) for key, values := range c.Request.Header { headers[key] = values[0] } - - // Convert headers to JSON - headersJSON, err := json.Marshal(headers) - if err != nil { - c.String(http.StatusInternalServerError, "Error converting headers to JSON") - return - } - - c.Data(http.StatusOK, "application/json", headersJSON) + c.JSON(http.StatusOK, headers) } // @Summary Get environment variables -// @Description Get the env variables available to the api. This is behind auth under /a/ +// @Description Env dump so you can check secrets/configmaps/task params actually landed. Behind auth under /a/ // @ID env // @Produce json // @Security BearerAuth @@ -142,32 +105,87 @@ func HeadersHandler(c *gin.Context) { // @Router /a/env [get] // @Param Authorization header string true "Bearer token from app logs" default(Bearer ) func EnvHandler(c *gin.Context) { - envVariables, _ := json.Marshal(GetEnvironmentVariables()) - c.Data(http.StatusOK, "application/json", envVariables) + c.JSON(http.StatusOK, GetEnvironmentVariables()) } // @Summary Debug -// @Description Get lots of info from running container headers/ips +// @Description Hostname, client ip, headers, uri โ€” good for routing tests // @ID debug // @Produce json -// @Success 200 {string} string "OK" +// @Success 200 {object} map[string]interface{} // @Router /debug [get] func DebugHandler(c *gin.Context) { - hostname, _ := os.Hostname() - sourceIP := getClientIP(c.Request) - headers := c.Request.Header - - debugInfo := gin.H{ + c.JSON(http.StatusOK, gin.H{ "Hostname": hostname, - "SourceIP": sourceIP, - "UserAgent": headers.Get("User-Agent"), - "Headers": headers, + "SourceIP": getClientIP(c.Request), + "UserAgent": c.Request.Header.Get("User-Agent"), + "Headers": c.Request.Header, "RequestURI": c.Request.RequestURI, + "Method": c.Request.Method, + }) +} + +// @Summary Fixed status code +// @Description Respond with whatever http status you pass (100-599). Great for ingress/retry testing +// @ID status +// @Produce plain +// @Param code path int true "HTTP status code" +// @Success 200 {string} string "status body" +// @Router /status/{code} [get] +func StatusHandler(c *gin.Context) { + code, err := strconv.Atoi(c.Param("code")) + if err != nil || code < 100 || code > 599 { + c.String(http.StatusBadRequest, "code must be an int between 100 and 599") + return } + c.String(code, "status=%d", code) +} - // Return the debug information in the response - c.JSON(http.StatusOK, debugInfo) +// @Summary Delay then OK +// @Description Sleep N seconds then return 200. Cap is MAX_DELAY_SECONDS env (default 120, hard max 600). For probe timeouts prefer LIVE/READY/STARTUP delaySeconds instead +// @ID delay +// @Produce plain +// @Param seconds path number true "seconds to sleep" +// @Success 200 {string} string "delayed" +// @Router /delay/{seconds} [get] +func DelayHandler(c *gin.Context) { + raw := c.Param("seconds") + secs, err := strconv.ParseFloat(raw, 64) + if err != nil || secs < 0 { + c.String(http.StatusBadRequest, "seconds must be a number >= 0") + return + } + requested := secs + secs = clampDelay(secs) + time.Sleep(time.Duration(secs * float64(time.Second))) + c.String(http.StatusOK, "delayed=%.3fs requested=%.3fs max=%.0fs", secs, requested, maxDelaySeconds()) +} + +// @Summary Echo request +// @Description Bounce method, path, query, headers and body back as json +// @ID echo +// @Accept plain +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Router /echo [get] +// @Router /echo [post] +// @Router /echo [put] +// @Router /echo [patch] +// @Router /echo [delete] +func EchoHandler(c *gin.Context) { + body, _ := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) // 1MB cap + headers := make(map[string][]string, len(c.Request.Header)) + for k, v := range c.Request.Header { + headers[k] = v + } + c.JSON(http.StatusOK, gin.H{ + "method": c.Request.Method, + "path": c.Request.URL.Path, + "query": c.Request.URL.Query(), + "headers": headers, + "body": string(body), + }) } // GetEnvironmentVariables returns a map of all environment variables @@ -182,16 +200,21 @@ func GetEnvironmentVariables() map[string]string { return envVariables } -// getClientIP extracts the client's IP address from the request. -func getClientIP(r *http.Request) string { - xForwardedFor := r.Header.Get("X-Forwarded-For") - if xForwardedFor != "" { - return xForwardedFor +func isTruthy(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "0", "false", "no", "off", "unhealthy", "notready", "not-ready", "not ready": + return false + default: + return true } +} +func getClientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + return strings.TrimSpace(strings.Split(xff, ",")[0]) + } if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { return ip } - - return "" + return r.RemoteAddr } diff --git a/handlers/metrics.go b/handlers/metrics.go new file mode 100644 index 0000000..3ecffcc --- /dev/null +++ b/handlers/metrics.go @@ -0,0 +1,95 @@ +package handlers + +import ( + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Prometheus setup โ€” client_golang is still the k8s scrape standard. +// We use a dedicated registry + standard RED-ish HTTP metrics + Go/process collectors. +// (promhttp.InstrumentHandler* is the other common pattern; middleware fits Gin better.) + +var ( + metricsRegistry = prometheus.NewRegistry() + + httpRequestsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "Total HTTP requests processed.", + }, + []string{"method", "path", "status"}, + ) + + httpRequestDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_request_duration_seconds", + Help: "HTTP request latency in seconds.", + Buckets: prometheus.DefBuckets, // 5ms โ€ฆ 10s โ€” fine for an API util + }, + []string{"method", "path", "status"}, + ) + + httpRequestsInFlight = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "http_requests_in_flight", + Help: "Number of HTTP requests currently being processed.", + }, + ) +) + +func init() { + metricsRegistry.MustRegister( + httpRequestsTotal, + httpRequestDuration, + httpRequestsInFlight, + collectors.NewGoCollector(), + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + ) +} + +// MetricsMiddleware records request count, in-flight gauge, and duration. +// Path label uses gin FullPath (route template) so cardinality stays bounded. +func MetricsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // don't instrument the scrape endpoint itself + if c.Request.URL.Path == "/metrics" { + c.Next() + return + } + + start := time.Now() + httpRequestsInFlight.Inc() + c.Next() + httpRequestsInFlight.Dec() + + path := c.FullPath() + if path == "" { + path = "unmatched" + } + status := strconv.Itoa(c.Writer.Status()) + method := c.Request.Method + + httpRequestsTotal.WithLabelValues(method, path, status).Inc() + httpRequestDuration.WithLabelValues(method, path, status).Observe(time.Since(start).Seconds()) + } +} + +// @Summary Prometheus metrics +// @Description Prometheus scrape endpoint (Go + process + HTTP RED metrics) +// @ID metrics +// @Produce plain +// @Success 200 {string} string "metrics" +// @Router /metrics [get] +func PrometheusMetricsHandler() gin.HandlerFunc { + h := promhttp.HandlerFor(metricsRegistry, promhttp.HandlerOpts{ + EnableOpenMetrics: true, + }) + return func(c *gin.Context) { + h.ServeHTTP(c.Writer, c.Request) + } +} diff --git a/handlers/probes.go b/handlers/probes.go new file mode 100644 index 0000000..6dbe54f --- /dev/null +++ b/handlers/probes.go @@ -0,0 +1,464 @@ +package handlers + +import ( + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// Probe modes โ€” delaySeconds is orthogonal (applied before the status decision, except latched startup). +const ( + ProbeModeOK = "ok" + ProbeModeFail = "fail" + ProbeModeDelay = "delay" // same outcome as ok; name makes "timeout testing" configs obvious + ProbeModeFlap = "flap" // alternate ok/fail on a timer or every N requests +) + +const ( + defaultMaxDelaySec = 120.0 // /delay, probe delaySeconds, proxy timeout + hardMaxDelaySec = 600.0 // absolute ceiling even if MAX_DELAY_SECONDS is wild + defaultFlapSec = 5.0 +) + +// maxDelaySeconds is shared by probes, /delay, and /proxy timeouts. +// Override with env MAX_DELAY_SECONDS (default 120, hard cap 600). +func maxDelaySeconds() float64 { + if v, ok := os.LookupEnv("MAX_DELAY_SECONDS"); ok { + if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 { + if f > hardMaxDelaySec { + return hardMaxDelaySec + } + return f + } + } + return defaultMaxDelaySec +} + +// ProbeConfig is the knobs for one probe type (live / ready / startup). +type ProbeConfig struct { + Mode string `json:"mode"` // ok | fail | delay | flap + DelaySeconds float64 `json:"delaySeconds"` // sleep before answering (0โ€“30) + // Startup only: wall-clock seconds from process start before a success is allowed. + BootDelaySeconds float64 `json:"bootDelaySeconds,omitempty"` + // Flap (mode=flap): + // flapSeconds โ€” wall-clock half-period: ok for N sec, fail for N sec, repeat (default 5). + // flapEvery โ€” if > 0, every Nth request fails instead of using the clock (handy for tests). + FlapSeconds float64 `json:"flapSeconds,omitempty"` + FlapEvery int `json:"flapEvery,omitempty"` +} + +// ProbeSnapshot is what /a/control/probes returns (includes runtime bits). +type ProbeSnapshot struct { + Live ProbeConfig `json:"live"` + Ready ProbeConfig `json:"ready"` + Startup ProbeConfig `json:"startup"` + StartupLatched bool `json:"startupLatched"` + UptimeSeconds float64 `json:"uptimeSeconds"` + StartupWouldPass bool `json:"startupWouldPass"` + // Which half of a time-based flap we are in right now (ok|fail|n/a). + LiveFlapPhase string `json:"liveFlapPhase,omitempty"` + ReadyFlapPhase string `json:"readyFlapPhase,omitempty"` + StartupFlapPhase string `json:"startupFlapPhase,omitempty"` +} + +// ProbeUpdate is the body for PUT /a/control/probes (all fields optional). +type ProbeUpdate struct { + Live *ProbeConfig `json:"live,omitempty"` + Ready *ProbeConfig `json:"ready,omitempty"` + Startup *ProbeConfig `json:"startup,omitempty"` + ResetStartupLatch *bool `json:"resetStartupLatch,omitempty"` +} + +type probeState struct { + mu sync.Mutex + live ProbeConfig + ready ProbeConfig + startup ProbeConfig + startupLatched bool + startedAt time.Time + // request counters for flapEvery + liveHits int + readyHits int + startupHits int +} + +var probes = &probeState{ + live: ProbeConfig{Mode: ProbeModeOK}, + ready: ProbeConfig{Mode: ProbeModeOK}, + startup: ProbeConfig{Mode: ProbeModeOK}, + startedAt: time.Now(), +} + +// InitProbesFromEnv seeds probe config from environment (call once at process start). +// +// LIVE_MODE / HEALTHY_MODE / HEALTHY + LIVE_DELAY + LIVE_FLAP_SECONDS / LIVE_FLAP_EVERY +// READY_MODE / READY + READY_DELAY + READY_FLAP_* +// STARTUP_MODE / STARTUP + STARTUP_DELAY + STARTUP_BOOT_DELAY + STARTUP_FLAP_* +func InitProbesFromEnv() { + probes.mu.Lock() + defer probes.mu.Unlock() + + probes.startedAt = time.Now() + probes.startupLatched = false + probes.liveHits = 0 + probes.readyHits = 0 + probes.startupHits = 0 + + probes.live = ProbeConfig{ + Mode: envMode("LIVE_MODE", "HEALTHY_MODE", "HEALTHY"), + DelaySeconds: envDelay("LIVE_DELAY", "HEALTHY_DELAY"), + FlapSeconds: envDelay("LIVE_FLAP_SECONDS"), + FlapEvery: envInt("LIVE_FLAP_EVERY"), + } + probes.ready = ProbeConfig{ + Mode: envMode("READY_MODE", "READY"), + DelaySeconds: envDelay("READY_DELAY"), + FlapSeconds: envDelay("READY_FLAP_SECONDS"), + FlapEvery: envInt("READY_FLAP_EVERY"), + } + probes.startup = ProbeConfig{ + Mode: envMode("STARTUP_MODE", "STARTUP"), + DelaySeconds: envDelay("STARTUP_DELAY"), + BootDelaySeconds: envDelay("STARTUP_BOOT_DELAY"), + FlapSeconds: envDelay("STARTUP_FLAP_SECONDS"), + FlapEvery: envInt("STARTUP_FLAP_EVERY"), + } +} + +func envMode(keys ...string) string { + for _, k := range keys { + if v, ok := os.LookupEnv(k); ok { + return normalizeMode(v) + } + } + return ProbeModeOK +} + +func envDelay(keys ...string) float64 { + for _, k := range keys { + if v, ok := os.LookupEnv(k); ok { + f, err := strconv.ParseFloat(v, 64) + if err == nil { + return clampDelay(f) + } + } + } + return 0 +} + +func envInt(keys ...string) int { + for _, k := range keys { + if v, ok := os.LookupEnv(k); ok { + n, err := strconv.Atoi(v) + if err == nil && n > 0 { + return n + } + } + } + return 0 +} + +func normalizeMode(s string) string { + switch strings.ToLower(strings.TrimSpace(s)) { + case ProbeModeFail, "0", "false", "no", "off", "unhealthy", "notready", "not-ready", "not ready": + return ProbeModeFail + case ProbeModeDelay, "slow": + return ProbeModeDelay + case ProbeModeFlap, "flapping", "oscillate": + return ProbeModeFlap + default: + return ProbeModeOK + } +} + +func clampDelay(d float64) float64 { + if d < 0 { + return 0 + } + max := maxDelaySeconds() + if d > max { + return max + } + return d +} + +func (c ProbeConfig) normalized() ProbeConfig { + c.Mode = normalizeMode(c.Mode) + c.DelaySeconds = clampDelay(c.DelaySeconds) + c.BootDelaySeconds = clampDelay(c.BootDelaySeconds) + if c.FlapSeconds < 0 { + c.FlapSeconds = 0 + } + if c.FlapSeconds > maxDelaySeconds() { + c.FlapSeconds = maxDelaySeconds() + } + if c.FlapEvery < 0 { + c.FlapEvery = 0 + } + return c +} + +func (c ProbeConfig) isHardFail() bool { + return c.Mode == ProbeModeFail +} + +func (c ProbeConfig) isFlap() bool { + return c.Mode == ProbeModeFlap +} + +// flapFail decides if this request is on the fail half of a flap. +// hitCount should already include this request when using flapEvery. +func (c ProbeConfig) flapFail(startedAt time.Time, hitCount int) bool { + c = c.normalized() + if c.FlapEvery > 0 { + // every Nth request fails: hits N, 2N, 3N, ... + return hitCount > 0 && hitCount%c.FlapEvery == 0 + } + period := c.FlapSeconds + if period <= 0 { + period = defaultFlapSec + } + // even windows = ok, odd windows = fail + phase := int(time.Since(startedAt).Seconds()/period) % 2 + return phase == 1 +} + +func (c ProbeConfig) flapPhase(startedAt time.Time, hitCount int) string { + if !c.isFlap() { + return "" + } + if c.flapFail(startedAt, hitCount) { + return "fail" + } + return "ok" +} + +// applyProbe runs delay + status for live/ready (not startup). +// name is "live" or "ready" for hit counters. +func applyProbe(c *gin.Context, name, queryKey, okBody, failBody string) { + // one-shot query overrides first (no counter bump needed for pure override... still bump so flapEvery stays predictable) + probes.mu.Lock() + var cfg ProbeConfig + var hits int + switch name { + case "live": + probes.liveHits++ + hits = probes.liveHits + cfg = probes.live.normalized() + default: + probes.readyHits++ + hits = probes.readyHits + cfg = probes.ready.normalized() + } + startedAt := probes.startedAt + probes.mu.Unlock() + + // query overrides (curl only; kube never sends these) + if q := c.Query("ok"); q != "" { + sleepDelay(cfg.DelaySeconds) + if !isTruthy(q) { + c.String(http.StatusServiceUnavailable, failBody) + return + } + c.String(http.StatusOK, okBody) + return + } + if q := c.Query(queryKey); q != "" { + sleepDelay(cfg.DelaySeconds) + if !isTruthy(q) { + c.String(http.StatusServiceUnavailable, failBody) + return + } + c.String(http.StatusOK, okBody) + return + } + + sleepDelay(cfg.DelaySeconds) + + fail := cfg.isHardFail() + if cfg.isFlap() { + fail = cfg.flapFail(startedAt, hits) + } + + if fail { + c.String(http.StatusServiceUnavailable, failBody) + return + } + c.String(http.StatusOK, okBody) +} + +func sleepDelay(seconds float64) { + if seconds > 0 { + time.Sleep(time.Duration(seconds * float64(time.Second))) + } +} + +// --- HTTP handlers: live / ready / startup --- + +// @Summary Liveness (livez) +// @Description Kube liveness. 200 = fine; 503 = restart. Modes: ok|fail|delay|flap. LIVE_* env or /a/control/probes. Aliases: /healthz /health +// @ID livez +// @Produce plain +// @Param ok query string false "one-shot force fail with 0/false (curl only)" +// @Success 200 {string} string "ok" +// @Failure 503 {string} string "unhealthy" +// @Router /livez [get] +func LiveHandler(c *gin.Context) { + applyProbe(c, "live", "healthy", "ok", "unhealthy") +} + +func HealthHandler(c *gin.Context) { LiveHandler(c) } +func HealthzHandler(c *gin.Context) { LiveHandler(c) } + +// @Summary Readiness (readyz) +// @Description Kube readiness. 200 = take traffic; 503 = leave Service endpoints. Modes: ok|fail|delay|flap. Alias: /ready +// @ID readyz +// @Produce plain +// @Param ok query string false "one-shot force fail (curl only)" +// @Success 200 {string} string "ready" +// @Failure 503 {string} string "not ready" +// @Router /readyz [get] +func ReadyHandler(c *gin.Context) { + applyProbe(c, "ready", "ready", "ready", "not ready") +} + +func ReadyzHandler(c *gin.Context) { ReadyHandler(c) } + +// @Summary Startup (startupz) +// @Description Cold start latch: 503 until bootDelay, then sticky 200. mode=fail never latches. mode=flap oscillates until a success latches (or forever if you only hit fail phases โ€” use flap carefully here). +// @ID startupz +// @Produce plain +// @Success 200 {string} string "started" +// @Failure 503 {string} string "starting" +// @Router /startupz [get] +func StartupHandler(c *gin.Context) { + probes.mu.Lock() + cfg := probes.startup.normalized() + latched := probes.startupLatched + delay := cfg.DelaySeconds + if latched && !cfg.isHardFail() { + delay = 0 + } + probes.startupHits++ + hits := probes.startupHits + startedAt := probes.startedAt + probes.mu.Unlock() + + sleepDelay(delay) + + probes.mu.Lock() + defer probes.mu.Unlock() + cfg = probes.startup.normalized() + + if cfg.isHardFail() { + probes.startupLatched = false + c.String(http.StatusServiceUnavailable, "startup failed") + return + } + + if probes.startupLatched { + c.String(http.StatusOK, "started") + return + } + + elapsed := time.Since(probes.startedAt).Seconds() + if cfg.BootDelaySeconds > 0 && elapsed < cfg.BootDelaySeconds { + c.String(http.StatusServiceUnavailable, "starting") + return + } + + // flap during pre-latch: fail phases keep returning starting; ok phase latches + if cfg.isFlap() && cfg.flapFail(startedAt, hits) { + c.String(http.StatusServiceUnavailable, "starting") + return + } + + probes.startupLatched = true + c.String(http.StatusOK, "started") +} + +// --- control API --- + +// @Summary Get probe control state +// @Description Current live/ready/startup config + startup latch + flap phase + uptime +// @ID getProbes +// @Produce json +// @Security BearerAuth +// @Success 200 {object} ProbeSnapshot +// @Failure 401 {object} map[string]string +// @Router /a/control/probes [get] +func GetProbesHandler(c *gin.Context) { + c.JSON(http.StatusOK, snapshotProbes()) +} + +// @Summary Update probe control state +// @Description Partial update of live/ready/startup. resetStartupLatch re-runs cold start without restarting the process. +// @ID putProbes +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body ProbeUpdate true "probe update" +// @Success 200 {object} ProbeSnapshot +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /a/control/probes [put] +func PutProbesHandler(c *gin.Context) { + var upd ProbeUpdate + if err := c.ShouldBindJSON(&upd); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()}) + return + } + + probes.mu.Lock() + if upd.Live != nil { + probes.live = upd.Live.normalized() + probes.liveHits = 0 + } + if upd.Ready != nil { + probes.ready = upd.Ready.normalized() + probes.readyHits = 0 + } + if upd.Startup != nil { + probes.startup = upd.Startup.normalized() + probes.startupHits = 0 + } + if upd.ResetStartupLatch != nil && *upd.ResetStartupLatch { + probes.startupLatched = false + probes.startedAt = time.Now() + probes.startupHits = 0 + } + if probes.startup.isHardFail() { + probes.startupLatched = false + } + probes.mu.Unlock() + + c.JSON(http.StatusOK, snapshotProbes()) +} + +func snapshotProbes() ProbeSnapshot { + probes.mu.Lock() + defer probes.mu.Unlock() + + uptime := time.Since(probes.startedAt).Seconds() + cfg := probes.startup.normalized() + wouldPass := probes.startupLatched || + (!cfg.isHardFail() && (cfg.BootDelaySeconds <= 0 || uptime >= cfg.BootDelaySeconds)) + + // phase uses current hit counts (next request will increment) + return ProbeSnapshot{ + Live: probes.live.normalized(), + Ready: probes.ready.normalized(), + Startup: cfg, + StartupLatched: probes.startupLatched, + UptimeSeconds: uptime, + StartupWouldPass: wouldPass && !cfg.isHardFail(), + LiveFlapPhase: probes.live.normalized().flapPhase(probes.startedAt, probes.liveHits+1), + ReadyFlapPhase: probes.ready.normalized().flapPhase(probes.startedAt, probes.readyHits+1), + StartupFlapPhase: probes.startup.normalized().flapPhase(probes.startedAt, probes.startupHits+1), + } +} diff --git a/handlers/proxy.go b/handlers/proxy.go new file mode 100644 index 0000000..0c26ba1 --- /dev/null +++ b/handlers/proxy.go @@ -0,0 +1,245 @@ +package handlers + +import ( + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// hop-by-hop headers we never copy outbound +var skipForwardHeaders = map[string]bool{ + "connection": true, + "keep-alive": true, + "proxy-authenticate": true, + "proxy-authorization": true, + "te": true, + "trailers": true, + "transfer-encoding": true, + "upgrade": true, + "content-length": true, + "host": true, +} + +// credentials / session material โ€” not forwarded unless forwardSensitiveHeaders=true +var sensitiveForwardHeaders = map[string]bool{ + "authorization": true, + "proxy-authorization": true, + "cookie": true, + "set-cookie": true, +} + +// ProxyRequest is the JSON body for POST /a/proxy. +// North-south hits this pod; we call another URL east-west (auth required โ€” open proxy is SSRF). +type ProxyRequest struct { + // Absolute URL to call, e.g. http://other-api:8080/debug + URL string `json:"url" binding:"required"` + // HTTP method (default GET) + Method string `json:"method,omitempty"` + // Extra headers to set/override on the outbound request + Headers map[string]string `json:"headers,omitempty"` + // Optional body (string; use for JSON text, form, etc.) + Body string `json:"body,omitempty"` + // Timeout for the outbound call (default 10; capped by MAX_DELAY_SECONDS) + TimeoutSeconds float64 `json:"timeoutSeconds,omitempty"` + // When true (default), copy inbound request headers onto the outbound call + // (minus hop-by-hop). Tracing headers like X-Request-Id ride along. + ForwardIncomingHeaders *bool `json:"forwardIncomingHeaders,omitempty"` + // When true, also forward Authorization / Cookie from the inbound request. + // Default false so your bearer token for *this* api is not sent east-west by accident. + ForwardSensitiveHeaders bool `json:"forwardSensitiveHeaders,omitempty"` + // When true, return the upstream body as the raw HTTP response (status + headers from upstream). + // Default false โ†’ JSON wrap with request/response/meta (includes upstream headers + body). + Raw bool `json:"raw,omitempty"` +} + +// @Summary Proxy / hop to another service (auth) +// @Description Auth required. Northโ†’south hits this pod; this pod calls url east-west. Default JSON wrap includes upstream status, headers, and body. Open proxy would be SSRF โ€” keep behind bearer. +// @ID proxy +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param body body ProxyRequest true "proxy request" +// @Param url query string false "absolute url (GET form)" +// @Param method query string false "HTTP method for GET form (default GET)" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 502 {object} map[string]interface{} +// @Router /a/proxy [post] +// @Router /a/proxy [get] +// @Param Authorization header string true "Bearer token" default(Bearer ) +func ProxyHandler(c *gin.Context) { + var req ProxyRequest + + // GET convenience: /a/proxy?url=http://svc:8080/debug&method=GET + if c.Request.Method == http.MethodGet { + req.URL = c.Query("url") + req.Method = c.DefaultQuery("method", http.MethodGet) + if t := c.Query("timeoutSeconds"); t != "" { + if f, err := strconv.ParseFloat(t, 64); err == nil { + req.TimeoutSeconds = f + } + } + if c.Query("forwardIncomingHeaders") == "0" || c.Query("forwardIncomingHeaders") == "false" { + f := false + req.ForwardIncomingHeaders = &f + } + if c.Query("forwardSensitiveHeaders") == "1" || c.Query("forwardSensitiveHeaders") == "true" { + req.ForwardSensitiveHeaders = true + } + if c.Query("raw") == "1" || c.Query("raw") == "true" { + req.Raw = true + } + } else { + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()}) + return + } + } + + if strings.TrimSpace(req.URL) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"}) + return + } + u, err := url.Parse(req.URL) + if err != nil || u.Scheme == "" || u.Host == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "url must be absolute, e.g. http://other-svc:8080/debug"}) + return + } + if u.Scheme != "http" && u.Scheme != "https" { + c.JSON(http.StatusBadRequest, gin.H{"error": "url scheme must be http or https"}) + return + } + + method := strings.ToUpper(strings.TrimSpace(req.Method)) + if method == "" { + method = http.MethodGet + } + + timeout := req.TimeoutSeconds + if timeout <= 0 { + timeout = 10 + } + timeout = clampProxyTimeout(timeout) + + forward := true + if req.ForwardIncomingHeaders != nil { + forward = *req.ForwardIncomingHeaders + } + + var bodyReader io.Reader + if req.Body != "" { + bodyReader = strings.NewReader(req.Body) + } + + outReq, err := http.NewRequestWithContext(c.Request.Context(), method, req.URL, bodyReader) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 1) optional: copy north-south inbound headers โ†’ east-west outbound + if forward { + for k, vals := range c.Request.Header { + lk := strings.ToLower(k) + if skipForwardHeaders[lk] { + continue + } + if sensitiveForwardHeaders[lk] && !req.ForwardSensitiveHeaders { + continue + } + for _, v := range vals { + outReq.Header.Add(k, v) + } + } + } + + // 2) explicit headers win (including Authorization if you set it here on purpose) + for k, v := range req.Headers { + outReq.Header.Set(k, v) + } + + if outReq.Header.Get("X-Forwarded-By") == "" { + outReq.Header.Set("X-Forwarded-By", "cluster-utils-api") + } + hostname, _ := os.Hostname() + outReq.Header.Add("X-Cu-Proxy-Hop", hostname) + + client := &http.Client{Timeout: time.Duration(timeout * float64(time.Second))} + start := time.Now() + resp, err := client.Do(outReq) + elapsed := time.Since(start) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{ + "error": err.Error(), + "url": req.URL, + "method": method, + "durationMs": elapsed.Milliseconds(), + "timeoutSeconds": timeout, + }) + return + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) // 2MB cap in debug wrap + respHeaders := map[string][]string{} + for k, v := range resp.Header { + respHeaders[k] = v + } + + if req.Raw { + for k, vals := range resp.Header { + for _, v := range vals { + c.Writer.Header().Add(k, v) + } + } + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), respBody) + return + } + + sentHeaders := map[string][]string{} + for k, v := range outReq.Header { + sentHeaders[k] = v + } + + // Default wrap: full upstream response (status + headers + body) for mesh debugging + c.JSON(http.StatusOK, gin.H{ + "request": gin.H{ + "url": req.URL, + "method": method, + "headers": sentHeaders, + "body": req.Body, + }, + "response": gin.H{ + "status": resp.StatusCode, + "headers": respHeaders, + "body": string(respBody), + }, + "meta": gin.H{ + "durationMs": elapsed.Milliseconds(), + "timeoutSeconds": timeout, + "forwardIncomingHeaders": forward, + "forwardSensitiveHeaders": req.ForwardSensitiveHeaders, + "proxyHostname": hostname, + "inboundClientIP": getClientIP(c.Request), + "responseBodyTruncatedAtMB": 2, + }, + }) +} + +func clampProxyTimeout(seconds float64) float64 { + max := maxDelaySeconds() + if seconds > max { + return max + } + if seconds < 0.1 { + return 0.1 + } + return seconds +} diff --git a/k8s-cluster-util-apis.yml b/k8s-cluster-util-apis.yml index ffe8640..8609099 100644 --- a/k8s-cluster-util-apis.yml +++ b/k8s-cluster-util-apis.yml @@ -21,20 +21,62 @@ spec: - name: cluster-utils-api image: donkeyx/cluster-utils-api:latest imagePullPolicy: Always - livenessProbe: - httpGet: - path: /healthz - port: 8080 - periodSeconds: 10 + ports: + - name: http + containerPort: 8080 + # Short timeouts so LIVE/READY/STARTUP delaySeconds are easy to trip. + # startup runs until first success, then kube moves on to live+ready. startupProbe: httpGet: - path: /healthz - port: 8080 + path: /startupz + port: http + periodSeconds: 2 + timeoutSeconds: 1 failureThreshold: 30 + livenessProbe: + httpGet: + path: /livez + port: http periodSeconds: 10 + timeoutSeconds: 1 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /readyz + port: http + periodSeconds: 5 + timeoutSeconds: 1 + failureThreshold: 2 env: - name: PORT value: "8080" + # --- probe demos (uncomment what you want to break) --- + # cold start: stay "starting" for 15s then latch + # - name: STARTUP_BOOT_DELAY + # value: "15" + # fail readiness (pod stays up, leaves Service endpoints) + # - name: READY_MODE + # value: "fail" + # flap readiness (in/out of endpoints) + # - name: READY_MODE + # value: "flap" + # - name: READY_FLAP_SECONDS + # value: "5" + # slow readiness โ†’ probe timeout (timeoutSeconds: 1) + # - name: READY_MODE + # value: "delay" + # - name: READY_DELAY + # value: "3" + # fail liveness โ†’ restarts + # - name: LIVE_MODE + # value: "fail" + # flap liveness (restart thrash โ€” careful) + # - name: LIVE_MODE + # value: "flap" + # - name: LIVE_FLAP_SECONDS + # value: "10" + # - name: AUTH_TOKEN + # value: "fixed-token-for-tests" resources: requests: cpu: "0.1" @@ -48,10 +90,14 @@ apiVersion: v1 kind: Service metadata: name: cluster-utils-api-svc + labels: + app: cluster-utils + type: api spec: selector: app: cluster-utils type: api ports: - - port: 8080 - targetPort: 8080 + - name: http + port: 8080 + targetPort: http diff --git a/main.go b/main.go index 36c2338..7533c53 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,11 @@ // @title Cluster Util API // @version 2.0 -// @description This is a util api which lots of endpoints making it easy to test routing/ingress/egress -// @host localhost:8080 +// @description Drop-in HTTP util for testing probes, routing, headers, env/params and more in a cluster. Swagger "Try it out" uses the host you opened the UI on (or override with ?host=host:port&scheme=http on /api-docs/index.html). Authorize with Bearer token from logs or AUTH_TOKEN. // @BasePath / // @securityDefinitions.apikey BearerAuth // @in header // @name Authorization -// @description Type "Bearer" followed by a space and the token from the app logs on startup. +// @description Paste: Bearer (token from container logs, or AUTH_TOKEN env). Example: Bearer dev package main @@ -16,21 +15,30 @@ import ( "os" "strconv" - "cu-api/middleware" - "cu-api/routes" + "github.com/donkeyx/cluster-utils-api/handlers" + "github.com/donkeyx/cluster-utils-api/middleware" + "github.com/donkeyx/cluster-utils-api/routes" "github.com/gin-gonic/gin" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) +// Set via -ldflags at build time (see Makefile). +var ( + Version = "dev" + GitHash = "unknown" +) + var securityToken string func main() { - logger := setupLogger() defer logger.Sync() + handlers.SetBuildInfo(Version, GitHash) + handlers.InitProbesFromEnv() + gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(middleware.LoggerMiddleware(logger)) @@ -38,12 +46,28 @@ func main() { port := getEnvOrDefault("PORT", 8080) - securityToken = generateRandomToken(32) + // Optional fixed token for automated tests; otherwise random each start. + if t := os.Getenv("AUTH_TOKEN"); t != "" { + securityToken = t + } else { + securityToken = generateRandomToken(32) + } routes.SetupRouter(logger, securityToken, r) - logger.Info("App started on port:", zap.Int("port", port)) - logger.Info("Random Security Token", zap.String("token", securityToken)) - logger.Info("Curl Command", zap.String("command", getCurlCommand(port, securityToken))) + logger.Info("App started", + zap.Int("port", port), + zap.String("version", Version), + zap.String("gitHash", GitHash), + ) + // Greppable startup lines so people can find the bearer for /a/* endpoints. + logger.Info("auth token for /a/* endpoints (Authorization: Bearer )", + zap.String("token", securityToken), + zap.String("header", "Authorization: Bearer "+securityToken), + ) + logger.Info("example curl with auth", + zap.String("env", getCurlCommand(port, securityToken)), + zap.String("probes", fmt.Sprintf("curl -sS -H 'Authorization: Bearer %s' http://localhost:%d/a/control/probes | jq", securityToken, port)), + ) r.Run(fmt.Sprintf(":%d", port)) } @@ -58,27 +82,22 @@ func generateRandomToken(length int) string { } func getCurlCommand(port int, securityToken string) string { - variable := fmt.Sprintf("curl -H 'Authorization: Bearer %s' http://localhost:%d/a/env | jq", securityToken, port) - return variable + return fmt.Sprintf("curl -H 'Authorization: Bearer %s' http://localhost:%d/a/env | jq", securityToken, port) } func setupLogger() *zap.Logger { - config := zap.NewProductionConfig() config.Encoding = "json" config.EncoderConfig.TimeKey = "timestamp" - config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder // Optional: Use ISO8601 time format + config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder logger, err := config.Build() if err != nil { panic(err) } - return logger } -// getEnvOrDefault retrieves the value of the environment variable named by the key -// or returns the default value if the environment variable is not set. func getEnvOrDefault(key string, defaultValue int) int { if value, exists := os.LookupEnv(key); exists { if intValue, err := strconv.Atoi(value); err == nil { diff --git a/main_test.go b/main_test.go index 235b9fc..3366824 100644 --- a/main_test.go +++ b/main_test.go @@ -1,33 +1,354 @@ -// main_test.go package main import ( - "cu-api/routes" + "bytes" + "github.com/donkeyx/cluster-utils-api/handlers" + "github.com/donkeyx/cluster-utils-api/routes" + "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" + "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestHealthEndpoint(t *testing.T) { - expectedResponse := "OK" - - // Create a new Gin router +func setupTestRouter(token string) *gin.Engine { + gin.SetMode(gin.TestMode) + handlers.SetBuildInfo("test-ver", "abc123") + handlers.InitProbesFromEnv() + // reset to known defaults for tests (env may not be set) + resetProbesForTest() r := gin.New() logger := setupLogger() - routes.SetupRouter(logger, securityToken, r) + routes.SetupRouter(logger, token, r) + return r +} + +func resetProbesForTest() { + ok := true + body, _ := json.Marshal(handlers.ProbeUpdate{ + Live: &handlers.ProbeConfig{Mode: "ok", DelaySeconds: 0}, + Ready: &handlers.ProbeConfig{Mode: "ok", DelaySeconds: 0}, + Startup: &handlers.ProbeConfig{Mode: "ok", DelaySeconds: 0, BootDelaySeconds: 0}, + ResetStartupLatch: &ok, + }) + // use handler internals via HTTP would need router; call Put via package by spinning mini router + // simpler: hit control after router exists โ€” for setup, use PUT through a throwaway engine + r := gin.New() + r.PUT("/p", handlers.PutProbesHandler) + req := httptest.NewRequest(http.MethodPut, "/p", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) +} + +func TestHealthEndpoint(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "ok", rr.Body.String()) +} + +func TestLivezAlias(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/livez", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "ok", rr.Body.String()) +} + +func TestHealthUnhealthyQuery(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/healthz?ok=0", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusServiceUnavailable, rr.Code) + assert.Equal(t, "unhealthy", rr.Body.String()) +} + +func TestReadyNotReadyQuery(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/ready?ready=false", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusServiceUnavailable, rr.Code) + assert.Equal(t, "not ready", rr.Body.String()) +} - // Create a test HTTP request to the "/hello" endpoint - req, err := http.NewRequest("GET", "/health", nil) - if err != nil { - t.Fatal(err) +func TestReadyFailViaControl(t *testing.T) { + token := "tok" + r := setupTestRouter(token) + + body := `{"ready":{"mode":"fail","delaySeconds":0}}` + req := httptest.NewRequest(http.MethodPut, "/a/control/probes", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + + req2 := httptest.NewRequest(http.MethodGet, "/readyz", nil) + rr2 := httptest.NewRecorder() + r.ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusServiceUnavailable, rr2.Code) + assert.Equal(t, "not ready", rr2.Body.String()) +} + +func TestReadyFlapEvery(t *testing.T) { + token := "tok" + r := setupTestRouter(token) + + // every 2nd request fails + body := `{"ready":{"mode":"flap","delaySeconds":0,"flapEvery":2}}` + req := httptest.NewRequest(http.MethodPut, "/a/control/probes", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + + // hit 1 ok, hit 2 fail, hit 3 ok, hit 4 fail + codes := make([]int, 0, 4) + for i := 0; i < 4; i++ { + reqN := httptest.NewRequest(http.MethodGet, "/readyz", nil) + rrN := httptest.NewRecorder() + r.ServeHTTP(rrN, reqN) + codes = append(codes, rrN.Code) } + assert.Equal(t, []int{200, 503, 200, 503}, codes) +} + +func TestStartupLatchAndBootDelay(t *testing.T) { + token := "tok" + r := setupTestRouter(token) + + // set boot delay into the future + body := `{"startup":{"mode":"ok","delaySeconds":0,"bootDelaySeconds":2},"resetStartupLatch":true}` + req := httptest.NewRequest(http.MethodPut, "/a/control/probes", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + + // still starting + req2 := httptest.NewRequest(http.MethodGet, "/startupz", nil) + rr2 := httptest.NewRecorder() + r.ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusServiceUnavailable, rr2.Code) + assert.Equal(t, "starting", rr2.Body.String()) + + time.Sleep(2100 * time.Millisecond) + + // first success latches + req3 := httptest.NewRequest(http.MethodGet, "/startup", nil) + rr3 := httptest.NewRecorder() + r.ServeHTTP(rr3, req3) + assert.Equal(t, http.StatusOK, rr3.Code) + assert.Equal(t, "started", rr3.Body.String()) + + // sticky after latch even if we can't easily re-apply boot without reset + req4 := httptest.NewRequest(http.MethodGet, "/startupz", nil) + rr4 := httptest.NewRecorder() + r.ServeHTTP(rr4, req4) + assert.Equal(t, http.StatusOK, rr4.Code) +} + +func TestStartupFailNeverLatches(t *testing.T) { + token := "tok" + r := setupTestRouter(token) + + body := `{"startup":{"mode":"fail","delaySeconds":0,"bootDelaySeconds":0},"resetStartupLatch":true}` + req := httptest.NewRequest(http.MethodPut, "/a/control/probes", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + + req2 := httptest.NewRequest(http.MethodGet, "/startupz", nil) + rr2 := httptest.NewRecorder() + r.ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusServiceUnavailable, rr2.Code) + assert.Equal(t, "startup failed", rr2.Body.String()) +} + +func TestGetProbesAuth(t *testing.T) { + token := "secret" + r := setupTestRouter(token) + + req := httptest.NewRequest(http.MethodGet, "/a/control/probes", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusUnauthorized, rr.Code) + + req2 := httptest.NewRequest(http.MethodGet, "/a/control/probes", nil) + req2.Header.Set("Authorization", "Bearer "+token) + rr2 := httptest.NewRecorder() + r.ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusOK, rr2.Code) + assert.Contains(t, rr2.Body.String(), "startupLatched") +} + +func TestVersion(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/version", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "test-ver") + assert.Contains(t, rr.Body.String(), "abc123") +} + +func TestStatusCode(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/status/418", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, 418, rr.Code) + assert.Contains(t, rr.Body.String(), "418") +} + +func TestStatusBadCode(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/status/999", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) +} +func TestDelay(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/delay/0", nil) rr := httptest.NewRecorder() r.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) - assert.Equal(t, expectedResponse, rr.Body.String()) + assert.Contains(t, rr.Body.String(), "delayed=") +} + +func TestEcho(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodPost, "/echo?x=1", strings.NewReader(`{"hi":"there"}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + body := rr.Body.String() + assert.Contains(t, body, "POST") + assert.Contains(t, body, "hi") + assert.Contains(t, body, "x") +} + +func TestEnvAuth(t *testing.T) { + token := "secret-token" + r := setupTestRouter(token) + + req := httptest.NewRequest(http.MethodGet, "/a/env", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusUnauthorized, rr.Code) + + req2 := httptest.NewRequest(http.MethodGet, "/a/env", nil) + req2.Header.Set("Authorization", "Bearer "+token) + rr2 := httptest.NewRecorder() + r.ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusOK, rr2.Code) +} + +func TestPing(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "PONG", rr.Body.String()) +} + +func TestMetrics(t *testing.T) { + r := setupTestRouter("tok") + r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/ping", nil)) + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + body := rr.Body.String() + assert.Contains(t, body, "http_requests_total") + assert.Contains(t, body, "http_request_duration_seconds") + assert.Contains(t, body, "go_goroutines") +} + +func TestProxyRequiresAuth(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/a/proxy?url=http://example.com/", nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestProxyHopToSelf(t *testing.T) { + token := "tok" + gin.SetMode(gin.TestMode) + handlers.SetBuildInfo("test-ver", "abc123") + handlers.InitProbesFromEnv() + resetProbesForTest() + r := gin.New() + logger := setupLogger() + routes.SetupRouter(logger, token, r) + + srv := httptest.NewServer(r) + defer srv.Close() + + proxyURL := srv.URL + "/a/proxy?url=" + srv.URL + "/debug" + req, err := http.NewRequest(http.MethodGet, proxyURL, nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-Trace-Demo", "east-west-1") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var wrap map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&wrap)) + assert.Contains(t, wrap, "request") + assert.Contains(t, wrap, "response") + assert.Contains(t, wrap, "meta") + + // wrap includes upstream headers map + respObj := wrap["response"].(map[string]interface{}) + assert.Contains(t, respObj, "headers") + assert.Contains(t, respObj, "status") + assert.Contains(t, respObj, "body") + + body := respObj["body"].(string) + assert.Contains(t, body, "X-Trace-Demo") + assert.Contains(t, body, "east-west-1") + + // our bearer for this api should NOT be auto-forwarded east-west + assert.NotContains(t, body, "Bearer "+token) +} + +func TestProxyRequiresAbsoluteURL(t *testing.T) { + r := setupTestRouter("tok") + req := httptest.NewRequest(http.MethodGet, "/a/proxy?url=/debug", nil) + req.Header.Set("Authorization", "Bearer tok") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) } diff --git a/routes/routes.go b/routes/routes.go index 01b46a2..b3ad33d 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -1,39 +1,103 @@ package routes import ( - "cu-api/handlers" - "cu-api/middleware" + "github.com/donkeyx/cluster-utils-api/docs" + "github.com/donkeyx/cluster-utils-api/handlers" + "github.com/donkeyx/cluster-utils-api/middleware" "net/http" + "strings" + "sync" "github.com/gin-gonic/gin" "go.uber.org/zap" - _ "cu-api/docs" - swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" ) +// swaggerInfoMu guards docs.SwaggerInfo Host/Schemes when serving the UI for different origins. +var swaggerInfoMu sync.Mutex + func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) { + r.Use(handlers.MetricsMiddleware()) // Redirect to swagger docs r.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/api-docs/index.html") }) - r.GET("/api-docs/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + // Swagger UI: persist Authorize token in the browser; host/scheme follow where you opened + // the page (so port-forward / docker / cluster DNS all work). Optional overrides: + // /api-docs/index.html?host=my-svc:8080&scheme=http + r.GET("/api-docs/*any", swaggerHandler()) + r.GET("/help", handlers.HelpHandler) + r.GET("/version", handlers.VersionHandler) + r.GET("/metrics", handlers.PrometheusMetricsHandler()) - r.GET("/health", handlers.HealthHandler) + // Kube-style probes (plus older aliases) + // live = liveness โ†’ restart on fail + // ready = readiness โ†’ leave Service endpoints on fail + // startup = cold start latch โ†’ kube only until first success + r.GET("/livez", handlers.LiveHandler) r.GET("/healthz", handlers.HealthzHandler) - r.GET("/ready", handlers.ReadyHandler) + r.GET("/health", handlers.HealthHandler) + r.GET("/readyz", handlers.ReadyzHandler) + r.GET("/ready", handlers.ReadyHandler) + + r.GET("/startupz", handlers.StartupHandler) + r.GET("/startup", handlers.StartupHandler) r.GET("/headers", handlers.HeadersHandler) r.GET("/debug", handlers.DebugHandler) r.GET("/ping", handlers.PingHandler) + r.GET("/status/:code", handlers.StatusHandler) + r.GET("/delay/:seconds", handlers.DelayHandler) + r.Any("/echo", handlers.EchoHandler) + + // Sensitive / abusable โ€” bearer auth required (see README security section) authGroup := r.Group("/a") authGroup.Use(middleware.AuthMiddleware(logger, st)) authGroup.GET("/env", handlers.EnvHandler) + authGroup.GET("/control/probes", handlers.GetProbesHandler) + authGroup.PUT("/control/probes", handlers.PutProbesHandler) + // open /proxy would be SSRF (scan cluster, hit metadata, etc.) + authGroup.GET("/proxy", handlers.ProxyHandler) + authGroup.POST("/proxy", handlers.ProxyHandler) +} + +func swaggerHandler() gin.HandlerFunc { + // Empty host in the generated spec would also work; we set Host from the request + // so the Swagger top bar shows a real target and Try it out hits the right place. + handler := ginSwagger.WrapHandler( + swaggerFiles.Handler, + ginSwagger.PersistAuthorization(true), + ginSwagger.DefaultModelsExpandDepth(-1), + ) + + return func(c *gin.Context) { + host := strings.TrimSpace(c.Query("host")) + if host == "" { + host = c.Request.Host + } + + scheme := strings.ToLower(strings.TrimSpace(c.Query("scheme"))) + if scheme != "http" && scheme != "https" { + if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" { + scheme = "https" + } else { + scheme = "http" + } + } + + // Serialize updates to the global SwaggerInfo used when doc.json is generated. + swaggerInfoMu.Lock() + docs.SwaggerInfo.Host = host + docs.SwaggerInfo.Schemes = []string{scheme} + docs.SwaggerInfo.BasePath = "/" + handler(c) + swaggerInfoMu.Unlock() + } }