Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,42 @@ If still 401: hard-refresh swagger (stale token), confirm
`curl -sS -o /dev/null -w '%{http_code}\n' -H 'Authorization: Bearer dev' http://127.0.0.1:18080/a/env`
is `200`, or check `podman logs cu-api | grep '"token"'`.

**Host / port:** UI uses the host you opened. Override with
`?host=127.0.0.1:18080&scheme=http` if needed.
#### Change the Try-it-out API host (query params — most common)

By default Swagger calls **whatever host you opened the docs on**.
If that is wrong (port-map, ingress, in-cluster Service, tunnel), **pass the host on the docs URL** — you will do this a lot:

```text
# point Try-it-out at a different host:port
/api-docs/index.html?host=127.0.0.1:18080&scheme=http

# in-cluster Service from a port-forwarded docs UI
/api-docs/index.html?host=cluster-utils-api.default.svc.cluster.local:8080&scheme=http

# public ingress
/api-docs/index.html?host=api.example.com&scheme=https
```

| Query | Example | What it does |
|-------|---------|----------------|
| `host` | `my-svc.ns.svc:8080` | Host:port **Execute** / Try-it-out uses |
| `scheme` | `http` or `https` | Scheme for those calls |
| `theme` | `light` | Stock bright Swagger (dark is default) |

Bookmark the full URL once; every open keeps that target.

#### Or set it once at process start (env)

| Env | Example | Purpose |
|-----|---------|---------|
| `SWAGGER_HOST` | `api.example.com` or `cluster-utils-api.ns.svc:8080` | Default host:port for Try-it-out |
| `SWAGGER_SCHEME` | `https` or `http` | Default scheme |

**Priority:** query `?host=&scheme=` → env `SWAGGER_*` → request Host / `X-Forwarded-Proto`.
(`PORT` is only the process listen port; it does **not** set the public URL.)

**Theme:** dark by default (custom CSS skin — stock Swagger has no real dark mode).
Light: `?theme=light`.

---

Expand Down
35 changes: 35 additions & 0 deletions RELEASE-v2.5.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## cluster-utils-api v2.5.2 — Swagger dark skin + Try-it-out host

Polish release: usable dark Swagger UI and clear control over which host “Try it out” hits.

### Swagger UI skin
- Custom **dark theme by default** (stock Swagger has no first-class dark mode — we inject CSS)
- Donkeyx / terminal flair: shell-prompt topbar, phosphor code, scan grid
- `?theme=light` restores stock bright UI

### Try-it-out host / scheme
Swagger defaults to the host you opened. When that is wrong (port-map, ingress, in-cluster Service):

**Query params (most common — bookmark the URL):**
```text
/api-docs/index.html?host=my-svc.ns.svc:8080&scheme=http
/api-docs/index.html?host=api.example.com&scheme=https
```

**Or once at startup:**
| Env | Purpose |
|-----|---------|
| `SWAGGER_HOST` | Default host:port for Try-it-out |
| `SWAGGER_SCHEME` | `http` or `https` |

**Priority:** query → env → request Host / `X-Forwarded-Proto`

The Swagger **info panel shows the live target** (`scheme://host/`), where it came from, and how to override it.

### Docs
- Links to **this repo** (docs/source) and **cluster-utils** (pair toolkit)
- README + k8s sample updated for host override and image `2.5.2`

### Images
- `ghcr.io/donkeyx/cluster-utils-api:2.5.2` / `:latest`
- `donkeyx/cluster-utils-api:2.5.2` / `:latest`
4 changes: 2 additions & 2 deletions docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -847,12 +847,12 @@ const docTemplate = `{

// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "2.5.0",
Version: "2.5.2",
Host: "",
BasePath: "/",
Schemes: []string{},
Title: "Cluster Utils API",
Description: "HTTP sidekick for cluster-utils — drop into a namespace and poke probes, ingress headers, env, and east-west hops.\n\n• Authorize (top-right lock): Bearer <token> e.g. local podman → Bearer dev\n• Probes: /startupz /livez /readyz · Control: GET|PUT /a/control/probes\n• Proxy: GET /a/proxy?url=https://httpbin.org/get (or POST JSON body)\n• Pair: https://github.com/donkeyx/cluster-utils",
Description: "HTTP sidekick for cluster-utils — drop into a namespace and poke probes, ingress headers, env, and east-west hops.\n\n• Authorize (top-right lock): Bearer <token> e.g. local podman → Bearer dev\n• Probes: /startupz /livez /readyz · Control: GET|PUT /a/control/probes\n• Proxy: GET /a/proxy?url=https://httpbin.org/get (or POST JSON body)\n• Docs / source: https://github.com/donkeyx/cluster-utils-api\n• Pair (shell toolkit): https://github.com/donkeyx/cluster-utils",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
Expand Down
4 changes: 2 additions & 2 deletions docs/swagger.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"swagger": "2.0",
"info": {
"description": "HTTP sidekick for cluster-utils — drop into a namespace and poke probes, ingress headers, env, and east-west hops.\n\n• Authorize (top-right lock): Bearer \u0026lt;token\u0026gt; e.g. local podman → Bearer dev\n• Probes: /startupz /livez /readyz · Control: GET|PUT /a/control/probes\n• Proxy: GET /a/proxy?url=https://httpbin.org/get (or POST JSON body)\n• Pair: https://github.com/donkeyx/cluster-utils",
"description": "HTTP sidekick for cluster-utils — drop into a namespace and poke probes, ingress headers, env, and east-west hops.\n\n• Authorize (top-right lock): Bearer \u0026lt;token\u0026gt; e.g. local podman → Bearer dev\n• Probes: /startupz /livez /readyz · Control: GET|PUT /a/control/probes\n• Proxy: GET /a/proxy?url=https://httpbin.org/get (or POST JSON body)\n• Docs / source: https://github.com/donkeyx/cluster-utils-api\n• Pair (shell toolkit): https://github.com/donkeyx/cluster-utils",
"title": "Cluster Utils API",
"contact": {},
"version": "2.5.0"
"version": "2.5.2"
},
"basePath": "/",
"paths": {
Expand Down
5 changes: 3 additions & 2 deletions docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,10 @@ info:
• Authorize (top-right lock): Bearer <token> e.g. local podman → Bearer dev
• Probes: /startupz /livez /readyz · Control: GET|PUT /a/control/probes
• Proxy: GET /a/proxy?url=https://httpbin.org/get (or POST JSON body)
• Pair: https://github.com/donkeyx/cluster-utils
• Docs / source: https://github.com/donkeyx/cluster-utils-api
• Pair (shell toolkit): https://github.com/donkeyx/cluster-utils
title: Cluster Utils API
version: 2.5.0
version: 2.5.2
paths:
/a/control/probes:
get:
Expand Down
9 changes: 7 additions & 2 deletions k8s-cluster-util-apis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ spec:
- name: cluster-utils-api
# GHCR primary (Hub still works as mirror). Version tag → default pull IfNotPresent.
# :latest still forces Always in kube even without imagePullPolicy.
image: ghcr.io/donkeyx/cluster-utils-api:2.5.0
# Alternative: docker.io/donkeyx/cluster-utils-api:2.5.0
image: ghcr.io/donkeyx/cluster-utils-api:2.5.2
# Alternative: docker.io/donkeyx/cluster-utils-api:2.5.2
ports:
- name: http
containerPort: 8080
Expand Down Expand Up @@ -88,6 +88,11 @@ spec:
# value: "10"
# - name: AUTH_TOKEN
# value: "fixed-token-for-tests"
# Swagger Try-it-out public URL (optional; default = request Host)
# - name: SWAGGER_HOST
# value: cluster-utils-api.default.svc.cluster.local:8080
# - name: SWAGGER_SCHEME
# value: http
# --- OTEL traces (OTLP *push* to Alloy → Tempo; not scraped) ---
# - name: OTEL_SERVICE_NAME
# value: cluster-utils-api
Expand Down
13 changes: 11 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// @title Cluster Utils API
// @version 2.5.0
// @version 2.5.2
// @description HTTP sidekick for cluster-utils — drop into a namespace and poke probes, ingress headers, env, and east-west hops.
// @description
// @description • Authorize (top-right lock): Bearer <token> e.g. local podman → Bearer dev
// @description • Probes: /startupz /livez /readyz · Control: GET|PUT /a/control/probes
// @description • Proxy: GET /a/proxy?url=https://httpbin.org/get (or POST JSON body)
// @description • Pair: https://github.com/donkeyx/cluster-utils
// @description • Docs / source: https://github.com/donkeyx/cluster-utils-api
// @description • Pair (shell toolkit): https://github.com/donkeyx/cluster-utils
// @BasePath /
// @securityDefinitions.apikey BearerAuth
// @in header
Expand Down Expand Up @@ -98,6 +99,14 @@ func main() {
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)),
)
// Optional fixed Try-it-out target (Swagger host/scheme). Empty = use request Host.
if h, s := os.Getenv("SWAGGER_HOST"), os.Getenv("SWAGGER_SCHEME"); h != "" || s != "" {
logger.Info("swagger public URL override (Try-it-out)",
zap.String("SWAGGER_HOST", h),
zap.String("SWAGGER_SCHEME", s),
zap.String("note", "query ?host=&scheme= still wins per request"),
)
}

r.Run(fmt.Sprintf(":%d", port))
}
Expand Down
145 changes: 125 additions & 20 deletions routes/routes.go
Original file line number Diff line number Diff line change
@@ -1,23 +1,39 @@
package routes

import (
"github.com/donkeyx/cluster-utils-api/docs"
"github.com/donkeyx/cluster-utils-api/handlers"
"github.com/donkeyx/cluster-utils-api/middleware"
"bytes"
_ "embed"
"fmt"
"net/http"
"os"
"strings"
"sync"

"github.com/donkeyx/cluster-utils-api/docs"
"github.com/donkeyx/cluster-utils-api/handlers"
"github.com/donkeyx/cluster-utils-api/middleware"

"github.com/gin-gonic/gin"
"go.uber.org/zap"

swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)

// swaggerInfoMu guards docs.SwaggerInfo Host/Schemes when serving the UI for different origins.
// swaggerInfoMu guards docs.SwaggerInfo Host/Schemes/Description when serving the UI.
var swaggerInfoMu sync.Mutex

// swaggerBaseDescription is the static @description from swag (no per-request target line).
var (
swaggerBaseDescription string
swaggerBaseDescriptionOnce sync.Once
)

// darkCSS is injected into Swagger UI (gin-swagger has no first-class dark mode).
//
//go:embed swagger-dark.css
var darkCSS []byte

func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) {
// Middleware (otel / metrics / log / recover) is registered in main before this.

Expand All @@ -26,19 +42,16 @@ func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) {
c.Redirect(http.StatusFound, "/api-docs/index.html")
})

// 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
// Swagger UI: persist Authorize token; host/scheme for Try-it-out.
// Priority: ?host=&scheme= query > SWAGGER_HOST / SWAGGER_SCHEME env > request Host / X-Forwarded-Proto.
// Dark theme by default; ?theme=light for stock Swagger look.
r.GET("/api-docs/*any", swaggerHandler())

r.GET("/help", handlers.HelpHandler)
r.GET("/version", handlers.VersionHandler)
r.GET("/metrics", handlers.PrometheusMetricsHandler())

// 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("/health", handlers.HealthHandler)
Expand All @@ -63,42 +76,134 @@ func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) {
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.)
// Separate handlers so Swagger GET has no body (browsers reject GET+body).
authGroup.GET("/proxy", handlers.ProxyGetHandler)
authGroup.POST("/proxy", handlers.ProxyPostHandler)
}

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),
ginSwagger.DocExpansion("list"),
)

return func(c *gin.Context) {
// Serve our dark stylesheet (relative to /api-docs/)
any := c.Param("any")
if any == "/swagger-dark.css" || any == "swagger-dark.css" || strings.HasSuffix(any, "swagger-dark.css") {
c.Data(http.StatusOK, "text/css; charset=utf-8", darkCSS)
return
}

// Resolve Try-it-out server URL (not the listen bind — that's PORT).
// 1) query 2) SWAGGER_* env 3) request
hostSource := "request Host"
host := strings.TrimSpace(c.Query("host"))
if host == "" {
host = c.Request.Host
if host != "" {
hostSource = "URL ?host="
} else {
host = strings.TrimSpace(os.Getenv("SWAGGER_HOST"))
if host != "" {
hostSource = "SWAGGER_HOST env"
} else {
host = c.Request.Host
}
}

schemeSource := "request / X-Forwarded-Proto"
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"
if scheme == "http" || scheme == "https" {
schemeSource = "URL ?scheme="
} else {
scheme = strings.ToLower(strings.TrimSpace(os.Getenv("SWAGGER_SCHEME")))
if scheme == "http" || scheme == "https" {
schemeSource = "SWAGGER_SCHEME env"
} else {
scheme = "http"
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
scheme = "https"
} else {
scheme = "http"
}
}
}

light := strings.EqualFold(c.Query("theme"), "light")

// Serialize updates to the global SwaggerInfo used when doc.json is generated.
swaggerInfoMu.Lock()
swaggerBaseDescriptionOnce.Do(func() {
swaggerBaseDescription = docs.SwaggerInfo.Description
})
docs.SwaggerInfo.Host = host
docs.SwaggerInfo.Schemes = []string{scheme}
docs.SwaggerInfo.BasePath = "/"
// Live target + how to change it (shows in the Swagger info panel).
docs.SwaggerInfo.Description = swaggerBaseDescription + fmt.Sprintf(
"\n\n---\n\n**Try-it-out target (now):** `%s://%s/` \n"+
"_host from %s · scheme from %s_ \n\n"+
"Change for this tab: append query params, e.g. \n"+
"`/api-docs/index.html?host=my-svc.ns.svc:8080&scheme=http` \n"+
"Or set env `SWAGGER_HOST` / `SWAGGER_SCHEME` once at startup. \n"+
"Priority: **query → env → request Host**.",
scheme, host, hostSource, schemeSource,
)

// Capture HTML for index so we can inject dark CSS (stock swagger is bright white).
if !light && isSwaggerIndex(any) {
buf := &responseCapture{ResponseWriter: c.Writer, body: &bytes.Buffer{}, status: http.StatusOK}
c.Writer = buf
handler(c)
html := buf.body.String()
inject := `<link rel="stylesheet" type="text/css" href="./swagger-dark.css">` +
`<meta name="color-scheme" content="dark">`
if strings.Contains(html, "</head>") {
html = strings.Replace(html, "</head>", inject+"</head>", 1)
} else {
html = inject + html
}
c.Writer = buf.ResponseWriter
// Drop content-length from capture; write fresh body
for k, vv := range buf.Header() {
if strings.EqualFold(k, "Content-Length") {
continue
}
for _, v := range vv {
c.Writer.Header().Add(k, v)
}
}
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
c.Writer.WriteHeader(buf.status)
_, _ = c.Writer.Write([]byte(html))
swaggerInfoMu.Unlock()
return
}

handler(c)
swaggerInfoMu.Unlock()
}
}

func isSwaggerIndex(any string) bool {
any = strings.TrimPrefix(any, "/")
return any == "" || any == "index.html" || strings.HasSuffix(any, "/index.html")
}

// responseCapture buffers the handler response so we can rewrite HTML.
type responseCapture struct {
gin.ResponseWriter
body *bytes.Buffer
status int
}

func (w *responseCapture) Write(b []byte) (int, error) {
return w.body.Write(b)
}

func (w *responseCapture) WriteString(s string) (int, error) {
return w.body.WriteString(s)
}

func (w *responseCapture) WriteHeader(statusCode int) {
w.status = statusCode
}
Loading
Loading