A single Go service that discovers Docker containers labeled swagger-aggregator.enable=true,
fetches their OpenAPI/Swagger spec, and shows all of them in one Scalar
UI with a live up/down marker per API. There is no runtime CDN dependency.
Security: there is no login by default. Anyone who can reach the web server can read all specs and use the "Try it" proxy. Enable the built-in login or OIDC, or keep it on a private network. See SECURITY.md for what to know and how to report a vulnerability, and CONTRIBUTING.md (commits are signed off, DCO) if you want to contribute.
Two small Go programs (one image):
swagger-aggregatorβ the API: discovery, spec fetching, SSE, and a reverse proxy for "Try it" calls.swagger-aggregator-webβ serves the built Vue UI (web/dist, not embedded) and forwards/apito the API, so the browser sees one origin.
Two small Go programs from one module (the UI is built separately and served by the second one):
cmd/swagger-aggregator API process: flags -> app.Config -> app.New -> Run
cmd/swagger-aggregator-web static UI server + /api forwarder
internal/app composition root: builds and wires everything, owns lifecycle (the only place that knows concrete types)
internal/discovery source-agnostic core: Container model, Source/Handler contracts, Manager (fetch/probe loop), target parsing
internal/discovery/docker Docker source (depends on a small docker.API interface, not on the client type)
internal/discovery/kubernetes Kubernetes source: informers for Services, EndpointSlices, Ingress, IngressRoute, HTTPRoute, Gateway + a level-triggered reconcile
internal/transport Mux: routes requests by host (authenticated transport for the Kubernetes API server proxy only)
internal/registry in-memory state: services, name index, routes, change notification (versioned)
internal/fetcher bounded spec download + validation/normalisation (Prepare)
internal/server HTTP: JSON/SSE API, Try-it proxies, static web handler, health probe
internal/status up/down rule
Dependencies are injected through constructors and config structs; interfaces are defined where they are consumed
(discovery.Store, discovery.SpecFetcher, server.Store, docker.API, fetcher.Doer, client-go's kubernetes.Interface), and there are no package-level
variables or hidden defaults like http.DefaultClient inside the libraries. That is what makes the end-to-end test in
internal/app possible without a Docker daemon (fake docker.API in, real HTTP out).
Adding a source: implement discovery.Source (Run(ctx, handler)), resolve each workload to a discovery.Container
(using discovery.ParseTarget on its labels/annotations) and report it with Apply/Remove/Retain; add it to app.New.
Docker and Kubernetes are done this way and can run side by side.
Keep the repo in the WSL filesystem (e.g. ~/projects), not under /mnt/c.
docker compose up --build
# open http://localhost:8080The compose file starts a tecnativa/docker-socket-proxy, the API, the web server, and two demo services
(orders: OpenAPI 3 with a HEALTHCHECK; inventory: Swagger 2.0 without one, using the
path/name/server-url labels). Try docker compose stop orders and watch the marker turn red.
Locally the UI port comes from docker-compose.override.yml (loaded automatically). With Traefik see Traefik.
More combinations (own login, OIDC, MCP, two API instances, Traefik, a remote Kubernetes cluster) are ready-made in
examples/.
One image holds the API and the web server. Measured (this repository, docker build; "pull" is what a registry transfers, gzip):
| Build | On disk | Pull | |
|---|---|---|---|
default (docker build .) |
88 MB | 22 MB | the full API with Kubernetes support: what deploy/kubernetes uses |
--build-arg KUBERNETES=false |
60 MB | 16 MB | what docker compose builds: the API without client-go (-tags nokubernetes): 10 instead of 31 MB |
+ --build-arg COMPRESS_BINARIES=best |
40 MB | 15 MB | UPX-packed binaries: start ~0.08 s slower, ~5 MB more RAM per process |
+ --build-arg COMPRESS_BINARIES=lzma |
34 MB | 12.5 MB | smaller still, start ~0.35 s slower |
The big step is dropping Kubernetes support, which a Docker-only setup does not need (client-go and the Kubernetes API types are ~26 MB of
the API binary). --kubernetes in such an image fails at startup with a clear message; examples/kubernetes-remote.yml builds the full one.
UPX shrinks the files on disk but little of what is pulled (the registry compresses anyway); it also makes some security scanners suspicious and
every process start decompress the binary. It is off by default: use it only if disk space matters more than that. The web server (23 MB) has no
Kubernetes code; its UI is 6.5 MB (1.8 MB gzipped).
| Label | Default | Meaning |
|---|---|---|
swagger-aggregator.enable=true |
β (required) | opt in |
swagger-aggregator.port |
lowest exposed TCP port | port of the spec endpoint |
swagger-aggregator.path |
/openapi.json |
path of the spec |
swagger-aggregator.name |
compose service name, else container name | display name, URL key |
swagger-aggregator.server-url |
β (or derived from Traefik labels, see below) | public base URL; replaces servers (OpenAPI 3) or host/basePath/schemes (Swagger 2.0). Its path is replaced by the container root when "Try it" is proxied (like a Traefik StripPrefix), unless strip-prefix is false |
swagger-aggregator.strip-prefix |
true |
only with a server-url that has a path: true = the proxy in front strips that path (the workload sees /items for /api/items); false = it does not and the workload itself serves under it (/api/items stays /api/items) |
swagger-aggregator.scheme |
http |
https if the workload itself serves TLS. Its certificate is verified unless --insecure-upstream-tls is set (workloads are reached by IP, which certificates rarely cover) |
swagger-aggregator.group |
β | shown as group / name in the UI, sorts first |
swagger-aggregator.order |
0 |
sort key inside a group (integer) |
All label names share a prefix (swagger-aggregator, the on-switch is <prefix>.enable); change it with --label-prefix to run several instances on one
daemon (e.g. staging and production, each with its own prefix).
Containers are reached by IP on the configured network, so the app must share a network
with them. A container without exposed ports needs the port label (EXPOSE / compose
expose: both count). Invalid labels are shown as a down service with the reason.
Flags or environment variables (SWAGGER_AGGREGATOR_ prefix, - β _).
| Flag / env | Default | |
|---|---|---|
--listen / SWAGGER_AGGREGATOR_LISTEN |
:8080 |
HTTP listen address |
--docker-host / SWAGGER_AGGREGATOR_DOCKER_HOST |
$DOCKER_HOST, else local socket |
e.g. tcp://socket-proxy:2375 |
--network / SWAGGER_AGGREGATOR_NETWORK |
first network with an IP | network used to reach containers |
--probe-interval / SWAGGER_AGGREGATOR_PROBE_INTERVAL |
15s |
spec re-fetch (= liveness probe) interval; retry backoff cap |
--fetch-timeout / SWAGGER_AGGREGATOR_FETCH_TIMEOUT |
5s |
per-request timeout |
--max-spec-bytes / SWAGGER_AGGREGATOR_MAX_SPEC_BYTES |
10485760 |
spec size limit |
--shutdown-grace / SWAGGER_AGGREGATOR_SHUTDOWN_GRACE |
5s |
how long in-flight requests get to finish on shutdown, then connections are closed |
--label-prefix / SWAGGER_AGGREGATOR_LABEL_PREFIX |
swagger-aggregator |
label namespace to watch |
--base-path / SWAGGER_AGGREGATOR_BASE_PATH |
β | public path prefix the UI is served under, e.g. /docs; written into the same-origin proxy paths of the specs (/docs/api/proxy/<id>). Set the same value on swagger-aggregator-web |
--insecure-upstream-tls / SWAGGER_AGGREGATOR_INSECURE_UPSTREAM_TLS |
false |
skip certificate verification for workloads labeled scheme=https (spec fetch and "Try it") |
--prefer-published-ports / SWAGGER_AGGREGATOR_PREFER_PUBLISHED_PORTS |
false |
reach containers via their published host port (127.0.0.1:<port>) instead of the network IP |
--derive-server-url / SWAGGER_AGGREGATOR_DERIVE_SERVER_URL |
false |
derive a missing server-url from Traefik router labels (Docker, see Traefik) or from Ingress/IngressRoute objects (Kubernetes) |
--docker / SWAGGER_AGGREGATOR_DOCKER |
true |
watch Docker containers; set false when running in Kubernetes without a Docker socket |
--kubernetes / SWAGGER_AGGREGATOR_KUBERNETES |
false |
watch Kubernetes Services, see Kubernetes |
--kubeconfig / --kube-context |
β | kubeconfig path / context (default: in-cluster ServiceAccount if present, else KUBECONFIG / ~/.kube/config) |
--namespace / SWAGGER_AGGREGATOR_NAMESPACE |
all | namespace(s) to watch, repeatable or comma separated |
--kube-access / SWAGGER_AGGREGATOR_KUBE_ACCESS |
auto |
service (in-cluster DNS), apiserver (API server service proxy, works from outside), auto |
--cluster-domain |
cluster.local |
DNS suffix for --kube-access=service |
--log-level / SWAGGER_AGGREGATOR_LOG_LEVEL |
info |
debug/info/warn/error |
--log-format / SWAGGER_AGGREGATOR_LOG_FORMAT |
console |
console (human readable) or json (one object per line, for log collectors; the Kubernetes deployment uses it) |
Logging uses zap; client-go's own (klog) messages are routed through the same logger.
Validation. The configuration is checked as a whole at startup and every problem is reported at once, named by flag:
Error: invalid configuration (3 problems):
- --listen: "nope" is not host:port (e.g. :8080 or 127.0.0.1:8080)
- --probe-interval: 1ns is out of range: must be between 10ms and 24h0m0s
- --kubeconfig: has no effect because --kubernetes is disabled
It checks formats (listen address, label prefix, Docker host and network names, namespaces, cluster domain), ranges (intervals, timeouts, spec size), and
options that would silently do nothing (Kubernetes options while --kubernetes is off, Docker options while --docker is off). Values from the
environment are parsed strictly: SWAGGER_AGGREGATOR_PROBE_INTERVAL=15 (no unit) or =abc is an error, not a silent 0.
--kube-access=service is refused when no ServiceAccount is present (in-cluster DNS only works inside the cluster).
Both programs have a healthcheck subcommand (swagger-aggregator healthcheck, swagger-aggregator-web healthcheck) that
probes their own /healthz; the compose file uses it as the container HEALTHCHECK (the image has no shell or curl).
A service is up only if all available signals are positive; the most negative one wins:
- container not running β down (the spec is kept, the service stays listed)
- container has a HEALTHCHECK β must be
healthy(starting/unhealthy= down) - the periodic HTTP fetch of the spec URL must succeed and return a valid OpenAPI/Swagger document
Containers without a HEALTHCHECK are judged by 1 and 3 only. After a start event the app
retries the fetch with exponential backoff (1s, 2s, 4s β¦ capped at the probe interval).
A destroyed container is removed; a stopped one is only marked down. When a stopped container is
recreated under the same name (compose recreate), the new one takes over the entry and the last
known spec.
/status lists every discovered service, also those that never delivered a spec (they are not in the docs), and updates live
over the same event stream (a Live badge shows that it is connected; otherwise it polls). Light/dark follows the OS; it works on a phone.
- Summary tiles: services, up, down and the total number of endpoints. The Up and Down tiles are filters.
- Search: one box over name, spec title, group, problem, version, format, source and status; every word must match
(
shop kubernetes)./jumps to it,Escclears it. Plus a status switch (All / Up / Down) and, when services have groups, a group filter. The filter is in the URL (/status?q=billing&status=down), so a filtered view can be shared or reloaded. - Table: status, service (with the spec's title and version and, below, the problem), endpoints, replicas (
2/3up), source (Docker or Kubernetes), last seen (the exact time on hover), and an Open docs link. Columns sort. A row opens a detail panel: title, version, spec format (OpenAPI 3.x / Swagger 2.0), endpoints, source, health check, replicas, exact last-seen time, spec hash and the full problem. - Where the details come from: the spec (title, version, format, number of operations), Docker's health status, the replica group and the source that found the service. They change rarely, so they do not make the event stream fire on every probe.
| Endpoint | |
|---|---|
GET /api/services |
[{name,status,lastSeen,specHash,error,group,order}] |
GET /api/services/{name}/spec |
last known spec as JSON, ETag = spec hash |
GET /api/events |
SSE: event services with the full list on connect and on every change, : heartbeat comment every 15s |
GET /auth/users/events |
SSE for admins (auth=build_in): event users with the full user list on connect and on every change |
ANY /api/proxy/{containerID}/{path...} |
same-origin reverse proxy to that container (used by "Try it", see below) |
ANY /api/scalar-proxy?scalar_url=β¦ |
Scalar's proxyUrl endpoint: forwards a public URL below a registered service's server-url to that container |
GET /healthz |
ok |
GET /spec/{openapi,asyncapi}.{json,yaml} |
this interface as OpenAPI 3.2 (REST, the event streams, MCP) and AsyncAPI 3.1 (the event streams), see Build your own frontend; /auth/* is the built-in login |
lastSeen is the last successful fetch. Changes that only move lastSeen do not emit an SSE event.
YAML specs are accepted and served as JSON.
The UI that ships with this program is only one client of an HTTP interface that is documented in the program itself:
the API page (/api-docs) shows it with Scalar, as two documents with a switcher:
- OpenAPI 3.2: the REST endpoints, the two event streams and the MCP endpoint (JSON-RPC, tools, examples). The event streams are described the
standard way, with
itemSchemaontext/event-stream(the event name as a constant, the JSON indatatyped throughcontentMediaType/contentSchema), which is what tools that generate typed event handlers read. That is why the document is 3.2:itemSchemadoes not exist in 3.1. - AsyncAPI 3.1: the same two event streams as channels and messages (
GET /api/events: the service list;GET /auth/users/events: the user list, admins only). Scalar renders AsyncAPI 3.x natively; the SDK generator for it is experimental, as Scalar says itself.
Both are also served as plain files for code generators and your own tooling: /spec/openapi.{json,yaml} and /spec/asyncapi.{json,yaml} (public, like
/healthz). The sources are internal/apidocs/openapi.yaml and asyncapi.yaml; tests check them against
the code (the schemas against the structs and real responses, every documented operation against the real handlers, the user stream and the MCP section
against the real endpoints). I checked that both validate and render in Scalar; I could not check what Scalar's cloud SDK generator makes of the itemSchema.
const services = await (await fetch('/api/services')).json() // [{name, status, specHash, ...}]
const spec = await (await fetch(`/api/services/${encodeURIComponent(services[0].name)}/spec`)).json()
const es = new EventSource('/api/events') // live: a full snapshot on every change
es.addEventListener('services', (e) => render(JSON.parse(e.data)))With auth=build_in, POST /auth/login (JSON) sets the session cookie, GET /auth/session tells which login to show;
auth=oidc redirects the browser to the provider.
Same origin is the simplest. Point --dir of swagger-aggregator-web at your build (it needs an index.html; unknown paths fall
back to it, so a single-page app works), or route your frontend and this server's paths (/api, /auth, /spec) to one host in your
reverse proxy. Then none of the following is needed.
A frontend on another origin needs the CORS list: SWAGGER_AGGREGATOR_WEB_CORS_ORIGINS=https://app.example.com (exact origins,
comma separated; * is refused, because the browser only sends cookies to an explicitly named origin). For those origins the server sends
Access-Control-Allow-Origin: <that origin> and Access-Control-Allow-Credentials: true, answers preflights itself (methods GET, POST, PUT, PATCH, DELETE; headers Content-Type, Accept, If-None-Match) and exposes ETag and Retry-After. The frontend must ask for credentials:
fetch(url, {credentials: "include"}) and new EventSource(url, {withCredentials: true}). See examples/custom-frontend.yml.
- The cookie is
SameSite=Laxby default. That works for a frontend on the same site (localhost:5173talking tolocalhost:3000, orapp.example.comtalking todocs.example.com). A frontend on another site needsSWAGGER_AGGREGATOR_WEB_COOKIE_SAMESITE=none; the cookie is thenSecure, i.e. HTTPS (or localhost).noneis refused without a CORS list. - The server checks
Originitself once a CORS list is set. WithSameSite=Nonethe browser no longer keeps other sites from sending the cookie, so requests that change something (POST,PUT,PATCH,DELETE) with anOriginthat is neither listed nor the server's own get403, and a preflight from an unlisted origin gets403. Requests without anOriginheader (curl, other servers) pass. The server's own origin is recognised by theHost(orX-Forwarded-Host) header: a proxy that rewritesHostmust sendX-Forwarded-Host. - Without a CORS list nothing changes: same origin only,
SameSite=Lax, no extra checks. - Tested in a real browser: a page on another origin signs in, reads the API and follows the event stream; an unlisted origin is blocked.
The
SameSite=Nonecookie attributes are unit-tested; a truly cross-site setup (two domains over HTTPS) was not run.
For tools and assistants that cannot do a browser login there is the token-protected MCP server.
Scalar sends test requests from the browser to the spec's servers URL, which is normally an internal address
the browser cannot reach (or a service without CORS headers) β "Failed to fetch". Two cases:
- No
server-url:serversbecomes the same-origin path/api/proxy/<containerID>(plus the original server's path, e.g./v1; Swagger 2.0:host/schemesremoved,basePathset) and the API forwards the call. server-urlset (explicitly, or derived from Traefik labels): the spec keeps showing that real public URL, also in code samples. The UI sets Scalar'sproxyUrlto/api/scalar-proxy, so the browser calls/api/scalar-proxy?scalar_url=<public URL>and the API maps it to the container. No CORS involved, and the public host does not even need to resolve from the browser. (Verified in Chrome against a fake host.)
Both proxies are not open proxies: the target is looked up in the registry (by container ID, or by matching a registered,
running service's server-url by scheme + host + path prefix, longest prefix wins). Everything else gets 404. Requests
are limited to 10 MB bodies and have dial/response-header timeouts; Cookie, Origin and Referer are not forwarded.
Scalar also sends our own relative /api/proxy/<id> URLs through scalar-proxy once it has made them absolute; those are
recognized by path only (the host is never contacted).
Route only the web service (port 3000) through Traefik and keep the API (api) unpublished:
TRAEFIK_NETWORK=proxy AGGREGATOR_HOST=docs.example.com \
docker compose -f docker-compose.yml -f docker-compose.traefik.yml up -d --builddocker-compose.traefik.yml joins web and api to your external Traefik network, sets SWAGGER_AGGREGATOR_NETWORK to
it (the API must share a network with your services), enables --derive-server-url, and carries the router labels
(optional auth middleware included, commented). Add swagger-aggregator.enable=true (+ labels) to your services as usual.
--derive-server-url(Docker part) fills a missingserver-urlfromtraefik.http.routers.<r>.ruleonly if exactly one public URL results: the rule must be exactlyHost(`x`)and the router must have TLS via labels (.tls=trueor.tls.*), givinghttps://x. Rules withPathPrefix,||, regexps, several different hosts, or TLS configured only on the entrypoint are skipped (a path prefix would need to know whether aStripPrefixmiddleware applies; entrypoint TLS is invisible in labels). An explicitserver-urllabel always wins.- Authentication middlewares on the router also protect
/api/proxy,/api/scalar-proxyand the event stream (same origin). - Traefik only routes healthy containers. The
webservice has a healthcheck (10 s interval), so after it is (re)created Traefik answers 404 until the first check passes. Expected, not a misconfiguration. - SSE works through Traefik, including with the
compressmiddleware on the router (frames are flushed immediately). Keep Traefik's idle timeout above the 15 s heartbeat. Usecompressfor the large UI bundle rather than a Go-side implementation.
Tested against a real Traefik (v3.7.16, Docker provider behind its own socket proxy, self-signed default certificate):
demo/traefik-local.yml is the setup used and can be reused to repeat it:
docker network create sa-traefik-net
export TRAEFIK_NETWORK=sa-traefik-net AGGREGATOR_HOST=docs.example.test
docker compose -p sa-traefik -f docker-compose.yml -f docker-compose.traefik.yml -f demo/traefik-local.yml up -d --build
curl -k --resolve docs.example.test:18443:127.0.0.1 https://docs.example.test:18443/api/servicesThe API can run inside the cluster or outside it (a laptop, another server) and documents Services, using Ingress and Traefik IngressRoute objects to find their public URLs. It can run next to the Docker source or instead of it.
What is documented. A Service labeled swagger-aggregator.enable=true. Everything else is optional:
| Attribute | Where | Default |
|---|---|---|
swagger-aggregator.enable=true |
label (it is the selector) | β (required) |
.port (number or port name), .path, .name, .group, .order, .server-url |
label or annotation; use annotations for URLs (labels cannot contain : or /) |
first TCP port of the Service, /openapi.json, <name>.<namespace> |
A Service is up when the spec fetch works and at least one of its endpoints is ready (EndpointSlices); scaling to zero shows it as down within seconds. A Service without a spec stays visible on the status page with the reason.
Public URL. With --derive-server-url, a missing server-url is derived from the objects that route to the Service, if exactly one URL results:
a standard Ingress rule (host + root path; https if spec.tls covers the host) or a Traefik IngressRoute (route Host(\x`)exactly,httpsifspec.tlsis set;traefik.io/v1alpha1, Traefik v3) or a Gateway API HTTPRoute (gateway.networking.k8s.io/v1: explicit hostnames, a rule that matches the whole host, plain Service backendRefs; httpsif a parent Gateway listener for the host uses protocolHTTPS). PathPrefix` rules, wildcard hosts and several different hosts are skipped
(a path prefix needs to know whether the controller strips it), and an explicit annotation always wins. The IngressRoute and Gateway API CRDs are optional:
without them that part is ignored and picked up when the CRD appears.
Inside the cluster (--kube-access=service, the default when a ServiceAccount is present): Services are reached by their DNS name.
kubectl apply -k deploy/kubernetes # namespace, read-only RBAC, one pod with the API and the web server
kubectl apply -f deploy/kubernetes/ingressroute.example.yaml # optional: expose the UI through Traefik
kubectl apply -f deploy/kubernetes/demo.yaml # optional: demo Services with an Ingress and an IngressRoute
kubectl apply -f demo/kubernetes-paths.yaml # optional: PathPrefix / StripPrefix routing examplesdeploy/kubernetes/deployment.yaml runs both programs in one pod (they talk over localhost), non-root, read-only filesystem, all capabilities
dropped, no Docker socket (--docker=false). The image is swagger-aggregator:local; build and push your own and change the tag.
rbac.yaml is cluster-wide read access to services, endpointslices, ingresses, ingressroutes, httproutes and gateways; for fewer namespaces use a Role per namespace
and pass --namespace. If RBAC is incomplete the source logs a warning after 30 s and continues with what it can read (no derived URLs).
Outside the cluster (--kube-access=apiserver, the default without a ServiceAccount): pod and service IPs are usually not routable, so the
aggregator goes through the API server's service proxy (/api/v1/namespaces/<ns>/services/<name>:<port>/proxy) using your kubeconfig credentials.
Only that one host gets the authenticated transport.
go run ./cmd/swagger-aggregator --docker=false --kubernetes --derive-server-url # current kubeconfig contextLimits of the API server proxy: it needs get on services/proxy, it speaks HTTP to the Service only, and it consumes the Authorization header, so
Try-it calls that need their own Authorization header do not work in this mode (use the in-cluster deployment for those).
The API instances need no coordination. Everything an API instance holds is derived from Docker or Kubernetes (read-only
discovery) plus a fetch of each spec: nothing is authoritative, so a second instance simply builds the same picture on its own.
Run two or more for availability (examples/scaled.yml, replicas: in the Kubernetes deployment).
- Server-Sent Events need no routing. An event stream is served by the instance that accepted the connection, from that instance's
own state, which is the same as everybody else's (only
lastSeentimestamps differ by milliseconds). A reconnect to another instance is harmless: every message is a full snapshot. There is no "right pod" to send events to, so no sticky sessions are needed. - What it costs: every instance runs its own watchers and fetches every spec itself, so probe traffic grows with the number of instances
(
--probe-intervalis the knob). In Kubernetes each instance also runs its own informers. - Instances must agree. They do: the spec hash does not depend on the instance (found and fixed while testing two API containers against three compose replicas: they had picked different replicas to document the service).
- Docker discovery is per host. One API instance sees the containers of one Docker daemon; there is no multi-host or Swarm support.
- The web server: stateless with
auth=noneoroidc(give every replica the sameSESSION_SECRET; the login throttle is per replica) and with MCP (stateless). Withauth=build_init is a single instance: the users are one SQLite file.
Leader election and Redis: possible, deliberately not built. A Kubernetes Lease (client-go leader election) could let only the
leader fetch specs, but then the others need the results, which means a shared store plus pub/sub so that each instance can still feed its
own event streams, and a gap or stale data whenever the leader changes. That trades a few small HTTP requests per probe interval for a new
dependency and new failure modes; it pays only with hundreds of services and many instances. redsync is a Redis lock library: it
serialises work, it does not share state, so it would not solve this problem. If the probe load ever matters, first raise
--probe-interval or run two instances instead of ten.
Tested: two API containers against one Docker daemon (consistent lists and spec hashes, event stream through the web server). Not tested: several pods in Kubernetes, several web replicas.
# backend (needs a reachable Docker daemon)
go run ./cmd/swagger-aggregator --listen :8080
# Docker Desktop: container IPs are NOT routable from the host, so a host-side API cannot reach them.
# Publish the containers' ports (ports: ["127.0.0.1::8000"]) and add --prefer-published-ports,
# or run the API in a container on the same network (the compose setup does that).
# frontend with hot reload (bun) - no Go web server needed: Vite serves the UI and proxies /api (incl. SSE), /auth and /mcp
# to the backend; /spec/openapi.* (the API page) is read from internal/apidocs/openapi.yaml by a Vite plugin
cd web && bun install && bun run dev # http://localhost:5173, backend http://localhost:8080 = the API server
cd web && bun run prod # the same without hot reload: builds the UI and serves the production build (http://localhost:4173)
# other backend address: SWAGGER_AGGREGATOR_BACKEND=http://host:port bun run dev
# with auth=none the API server alone is enough; to try the login (auth=build_in/oidc) or MCP, point the backend at the
# Go web server (swagger-aggregator-web), which answers /auth and /mcp
# production mode: build the UI, then serve it with the Go web server
cd web && bun install --frozen-lockfile && bun run build && cd .. # `bun run bundle` = vite only, no typecheck
go run ./cmd/swagger-aggregator-web --dir web/dist --api http://localhost:8080 # http://localhost:3000
# tests and lint (explicit patterns: ./... would also walk web/node_modules)
go test -race ./cmd/... ./internal/...
golangci-lint run ./cmd/... ./internal/... # needs golangci-lint v2.15+, config in .golangci.yml
# integration test against the REAL local Docker daemon (skips if there is none). It uses a unique label prefix,
# network and container names, so it does not show up in a swagger-aggregator instance already running on that daemon.
go test -tags=integration -count=1 -timeout 180s ./internal/discovery/docker -run Integration
# integration test against a Kubernetes cluster: creates and deletes a namespace, so it only runs when you name the context
SWAGGER_AGGREGATOR_IT_KUBE_CONTEXT=docker-desktop go test -tags=integration -count=1 -timeout 240s ./internal/app -run KubernetesIntegration
cd web && bun run typecheck
# frontend regression tests (Playwright against the production build + a mock API)
cd web && bunx playwright install chromium && bun run test:e2e
# or with the locally installed Chrome: PW_CHANNEL=chrome bun run test:e2eThe Playwright suite also runs the real Go web server with auth=build_in against the built UI (e2e/auth.spec.ts: setup, login, users); it builds the binary itself, so Go must be installed (or set E2E_WEB_BIN to a prebuilt one). It pins the Scalar behaviours listed below (no external requests, status flips keep position,
remount keeps the open document, stale hash cleanup, group/order, hide-offline, Try-it through the proxy).
Note: vue-tsc (typecheck) needs Node; the Docker image has none, so it builds with bun run bundle.
swagger-aggregator-web flags/env: --listen/SWAGGER_AGGREGATOR_WEB_LISTEN (:3000), --dir/SWAGGER_AGGREGATOR_WEB_DIR (web/dist),
--api/SWAGGER_AGGREGATOR_WEB_API (http://localhost:8080), the --auth/--oidc-*/--session-* options (Authentication), --cors-origins/SWAGGER_AGGREGATOR_WEB_CORS_ORIGINS and --cookie-samesite/SWAGGER_AGGREGATOR_WEB_COOKIE_SAMESITE (a frontend on another origin), --base-path/SWAGGER_AGGREGATOR_WEB_BASE_PATH (none; e.g. /docs,
stripped when present, so it works with or without a StripPrefix in front; set the same value on the API).
.github/workflows/ci.yml (GitHub Actions) and .gitlab-ci.yml (GitLab CI) run the same checks: go vet + go test -race, golangci-lint,
govulncheck, the web typecheck + build + Playwright e2e tests, rendering of the Kubernetes/compose files and a multi-arch (amd64, arm64)
image build. The image is pushed to ghcr.io/<owner>/<repo> (GitHub) or the project's container registry (GitLab) from the default branch and
from v* tags; merge/pull requests only prove that it builds. Neither pipeline has run yet: they were checked for valid YAML and by running
their commands locally, not on a runner. Pin the lint version and the base images once you want reproducible builds.
Off by default (SWAGGER_AGGREGATOR_WEB_AUTH=none). swagger-aggregator-web can protect everything it serves, the UI, /api/*
(including the event stream) and the Try-it proxies, in one of two ways. The API container stays internal and needs no auth of its own,
because it is not published. API calls without a session get 401 (the UI then reloads and shows the login). /healthz stays open for probes.
SWAGGER_AGGREGATOR_WEB_AUTH |
What |
|---|---|
none (default) |
no login |
build_in |
own login form and users stored in SQLite, with a user management page; see Built-in users |
oidc |
login at an OpenID Connect provider (Authelia, Keycloak, Authentik, Dex, Entra ID, ...); see below |
auth=oidc: browser navigations are redirected to the provider.
Variable (flag --x-y) |
Default | Meaning |
|---|---|---|
SWAGGER_AGGREGATOR_WEB_AUTH |
none |
oidc for this table |
SWAGGER_AGGREGATOR_WEB_OIDC_ISSUER |
β | issuer URL of the provider (its /.well-known/openid-configuration is used) |
SWAGGER_AGGREGATOR_WEB_OIDC_CLIENT_ID / ..._CLIENT_SECRET |
β | the client registered for this app |
SWAGGER_AGGREGATOR_WEB_OIDC_REDIRECT_URL |
β | public callback URL, <public url><base path>/auth/callback, registered at the provider |
SWAGGER_AGGREGATOR_WEB_SESSION_SECRET |
β | signs and encrypts the cookies, at least 32 characters (openssl rand -base64 48) |
SWAGGER_AGGREGATOR_WEB_OIDC_ALLOWED_GROUPS |
β | comma separated; only members may sign in |
SWAGGER_AGGREGATOR_WEB_OIDC_ALLOWED_EMAILS |
β | comma separated; alternative to groups (the provider must report the address as verified) |
SWAGGER_AGGREGATOR_WEB_OIDC_GROUPS_CLAIM |
groups |
claim with the user's groups (also read from userinfo if the ID token lacks it) |
SWAGGER_AGGREGATOR_WEB_OIDC_SCOPES |
openid,profile,email,groups |
requested scopes |
SWAGGER_AGGREGATOR_WEB_SESSION_TTL |
8h |
login lifetime |
Without allowed groups/emails every user the provider authenticates may sign in. Pass secrets as environment variables (a Kubernetes
Secret, a compose env_file), not as flags. Details: authorization code flow with PKCE, state and nonce, HttpOnly + SameSite=Lax
cookies (Secure when the redirect URL is https), local-only return paths. GET /auth/logout ends the local session only; the provider's
own session may sign you straight back in. The provider may start later than this server: login answers 503 until it is reachable.
Authelia (OIDC client, adapt to your Authelia version; the secret is stored hashed there):
identity_providers:
oidc:
clients:
- client_id: swagger-aggregator
client_name: Swagger Aggregator
client_secret: '$pbkdf2-sha512$...' # authelia crypto hash generate pbkdf2 --password '<secret>'
public: false
authorization_policy: two_factor
redirect_uris: [https://docs.example.com/auth/callback]
scopes: [openid, profile, email, groups]Verified end to end with Dex and a real browser (login, requested page restored, UI + event stream, logout); the flow itself is covered by
unit tests against a fake provider. Not tested against Authelia itself. As an alternative without any code in this app you can
keep auth=none and let Traefik (or nginx) ask Authelia through a forwardAuth middleware on the router of swagger-aggregator-web.
auth=build_in: the web server shows its own login form (PrimeVue + Tailwind, Vue) and keeps the users in a SQLite database
(GORM with modernc.org/sqlite, SQLite transpiled to Go: no CGO and no WebAssembly) in SWAGGER_AGGREGATOR_WEB_DATA_DIR (/data in the image and in compose, data otherwise).
The schema is created by Atlas migrations that are embedded in the binary and applied at startup.
- First start without users: every page shows a setup form. It needs the setup token that is printed in the log at startup
(
docker compose logs web) or set withSWAGGER_AGGREGATOR_WEB_SETUP_TOKEN; without the token nobody can claim a freshly started, reachable instance. Wrong tokens are throttled. The first account is an admin. - Or from the environment:
SWAGGER_AGGREGATOR_WEB_ADMIN_USER+SWAGGER_AGGREGATOR_WEB_ADMIN_PASSWORD(or..._ADMIN_PASSWORD_HASH, a bcrypt hash) create the admin on the first start instead of the setup page. The environment never overwrites an existing account. - Users page (admins,
/users): create users, set a new password, change the role, disable, delete. The list is live (Server-Sent Events,GET /auth/users/events): a change made by another admin or in another tab shows up without a reload. Roles:admin(manages users) anduser(reads the docs). The last active admin cannot be demoted, disabled or deleted, nobody can delete themselves. Every user can change their own password in the account menu. - Sessions: an encrypted, signed
HttpOnly+SameSite=Laxcookie (Securebehind HTTPS). A password change, disabling or deleting a user ends their sessions at once. The cookie secret isSWAGGER_AGGREGATOR_WEB_SESSION_SECRET, or, if unset, a key filesession.keyin the data dir (so logins survive restarts). - Passwords: bcrypt, 10-72 characters. Failed logins are delayed per username (1 s, 2 s, ... up to 30 s; a delay, not a lock-out, so nobody can keep a user out). Unknown users cost the same time as wrong passwords. State-changing requests must be JSON.
- Keep the data: mount a volume on the data dir (compose: the named volume
web-data, already there; Kubernetes: replace theemptyDirby a PersistentVolumeClaim). Without it the users are gone when the container is re-created and the setup form opens again. - Limits: one instance only (SQLite file; the deployment has one replica anyway), no two-factor login, no self-service password reset
(an admin sets a new one). If the last admin password is lost, stop the server and delete
swagger-aggregator.dbin the data dir (this removes all users), then start again to register a new admin.
| Variable | Default | Meaning |
|---|---|---|
SWAGGER_AGGREGATOR_WEB_AUTH |
none |
none, build_in or oidc |
SWAGGER_AGGREGATOR_WEB_DATA_DIR |
data (/data in the image) |
database and session.key |
SWAGGER_AGGREGATOR_WEB_ADMIN_USER / ..._ADMIN_PASSWORD / ..._ADMIN_PASSWORD_HASH |
β | first admin from the environment |
SWAGGER_AGGREGATOR_WEB_SETUP_TOKEN |
random, logged | token for the setup form |
SWAGGER_AGGREGATOR_WEB_SESSION_SECRET |
key file in the data dir | at least 32 characters |
SWAGGER_AGGREGATOR_WEB_SESSION_TTL |
8h |
login lifetime |
Schema changes: edit the models in internal/store/schema, then atlas migrate diff <name> --env gorm (see atlas.hcl; needs the
Atlas CLI, which the app itself does not). The first migration was generated by tools/migrate-gen, a separate Go
module so that the application does not depend on the GORM provider's MySQL/Postgres drivers. The Atlas CLI step itself is untested here.
SWAGGER_AGGREGATOR_WEB_MCP=true serves a Model Context Protocol server at /mcp (Streamable HTTP,
stateless, the official Go SDK). It is read-only: it cannot call the documented services and has no Try-it. Tools:
| Tool | Does |
|---|---|
list_services |
the services with status, group, spec title and version, number of endpoints, whether a spec exists and the last problem |
list_endpoints |
method, path, summary, operationId, tags of one service |
search_endpoints |
search all services by a word in name, path, summary, description, operationId or tags |
get_spec |
the complete spec of one service as JSON (can be large) |
Clients send Authorization: Bearer <token>; the token is SWAGGER_AGGREGATOR_WEB_MCP_TOKEN (at least 32 characters, e.g.
openssl rand -base64 32) and is required. /mcp sits outside the session login on purpose (assistants cannot do a browser login),
so treat the token like a password: whoever has it can read all specs. It is one shared token (no per-user tokens, no rate limit; put
that in the reverse proxy). Specs can be sensitive: they go to whatever model the client uses. Example client entry (Claude Code):
claude mcp add --transport http swagger-aggregator https://docs.example.com/mcp --header "Authorization: Bearer <token>"
(untested here; the server was tested with the Go SDK client and with curl).
- Do not mount
/var/run/docker.sockinto the app. The compose file puts adocker-socket-proxyin front (CONTAINERS=1,EVENTS=1;POSTstays off, so it is read-only) on aninternalnetwork that only the API shares.EVENTS,PINGandVERSIONare on by default in the proxy. - Container inspect can expose environment variables, including secrets. The app inspects
every labeled container (needed for health, network, ports) and only keeps labels/state/IP, but the
proxy still lets the app read the full inspect output, and
CONTAINERS=1covers all containers, not just labeled ones. Anyone who can compromise the app can read those. Keep the app patched and don't expose the proxy. - Authentication is off by default; see Authentication. Route only
swagger-aggregator-webthrough your reverse proxy and do not publish the API port (see Traefik). The spec endpoint serves whatever the documented services publish, and the Try-it proxies let any UI user send requests to every registered container, so treat the UI as sensitive as those services. - Hardening in compose: the two Go containers run non-root,
read_only,cap_drop: [ALL],no-new-privileges, with memory/pid limits;swagger-aggregator-websendsnosniff,Referrer-Policy: same-origin,X-Frame-Options: DENYand caches hashed assets as immutable. No CSP yet (Scalar's inline styles/workers need testing). The socket proxy only getsno-new-privileges(more would need testing against its HAProxy). Pin its image by digest in production. - Unauthenticated SSE clients each hold a connection; rate limiting belongs in the proxy.
The documentation is Scalar. The login, setup, status and users pages are Vue + PrimeVue 5 + Tailwind
CSS 4 (web/src/shell, web/src/views); Tailwind and PrimeVue are loaded only for those pages, so Scalar keeps its own styles (an e2e test checks
that).
PrimeVue 5 is not MIT. It is under the PrimeUI licence and needs a licence key, which the browser verifies offline (no network, no telemetry). This project uses the Community licence, which PrimeTek grants free of charge to individuals and to organisations below its limits (fewer than 5 developers and 10 employees, under 1 M USD revenue, under 3 M USD outside funding), renewed yearly by confirming that you still qualify. Whether you qualify is for you to check against the licence; it is not decided here.
- The key is not in the repository (the licence says it must not be published for others to use). It is given at build time:
VITE_PRIMEVUE_LICENSEforbun run dev/build(put it inweb/.env.local, which git ignores), andPRIMEVUE_LICENSEfor the Docker build (docker build --build-arg PRIMEVUE_LICENSE=...;docker composereads it fromPRIMEVUE_LICENSEin a git-ignored.env). In CI set it as a secret for the tests (the images CI publishes are built without it). Note that the key necessarily ends up inside the built JavaScript; the licence says it may and that it holds nothing sensitive. - Without a key everything works, but PrimeVue logs a console warning and shows a small red "Invalid PrimeUI License" badge in the corner of the login, setup, status and users pages. So anyone who clones this repository and builds it needs their own key (or lives with the badge).
- The key expires (the one in use here: 2027-10-24, plus a 30-day grace period). After that the badge appears until you renew it.
- What this means for open-sourcing the repository: the code can be published under a free licence, but PrimeVue 5 is a dependency with its
own terms that every user of the built UI has to meet. If that is unwanted, PrimeVue 4.5.5 (MIT, its last 4.x release, April 2026) is
the free alternative: it needs no key and gets no further updates, and the change back is small (
primevue@4.5.5,@primeuix/themes@2.0.3,primeicons@7.0.0, drop thelicenseoption inweb/src/main.ts).
Tested in Chrome with a mock backend. Findings:
- Reactive config changes β the component has a deep watcher on
configuration.- Status flips (title change), spec hash changes and
urlchanges keep the selected document and the scroll position; only the changed document is refetched. This works only because each source has an explicitslug; otherwise the slug is derived from the title and a status marker would change the document identity. - Adding/removing/reordering sources resets the view to the first document (the component calls
changeSelectedDocument(first)whenever the ordered slug list changes). - Mitigation (implemented): remount only when the set of slugs changes (
:key). The remount restores the open document and section from the URL hash. The exact pixel offset is not preserved (3000px β 4704px in my test, because it scrolls the section into view). If the open service disappears, the hash is cleared and the first document opens.
- Status flips (title change), spec hash changes and
- Empty
sourcesat startup β it does not crash and recovers once sources arrive, but the component then runs with its defaults: it loads fonts fromfonts.scalar.com(ignoringwithDefaultFonts: false) and logsDocument not found in configList. SoApiReferenceis mounted only after the first non-empty list. slugper source β supported (SourceConfiguration.slug) and used in the URL hash (#bravo/β¦).proxyUrlβ with a relativeproxyUrl, Scalar rewrites every request to an absolute URL as<proxyUrl>?scalar_url=<absolute URL>and leaves relative URLs alone (read from@scalar/helpers). Because it resolves our relativeserversto absolute URLs first, those go through the proxy as well (handled, see "Try it").
Also set: withDefaultFonts: false, telemetry: false, agent: { disabled: true } (otherwise "Ask AI" is shown and fonts come from Scalar's CDN).
With these, the page contacts only its own origin (asserted by the e2e suite). Scalar itself is loaded lazily (entry bundle ~74 KB).
The view has a "Hide offline services" checkbox (Scalar's sidebar-start slot, remembered in localStorage).
Verified on a real Docker daemon (Docker Desktop 29.8 + socket proxy), by hand and by go test -tags=integration:
both images build, both containers become healthy through the healthcheck subcommand while read_only/cap_drop: ALL/non-root,
the web server serves the UI and security headers, the API discovers containers through the socket proxy, and the integration
test covers start, stop (down, spec kept, no error), restart, destroy and health-status events (unhealthy β down,
healthy β up). Bugs found there and fixed: a stopped container was reported as "not attached to network", wrong binary names in
the image, vue-tsc under Bun.
Verified against a real Traefik 3.7.16 (see Traefik): the derived server-url (https://orders.example.test) matches a route Traefik
really serves; an explicit server-url label wins; the UI, API and event stream work through Traefik over HTTP/2 (snapshot at 0 s, a
container stop arrived 1 s later, heartbeats at 15 s and 30 s, stream open for 40 s); the same with the compress middleware active;
in Chrome the page contacted only the docs host and Try-it for the Traefik-derived public URL showed the container's response; a basic-auth
middleware on the router protected every path, including both proxies and the event stream.
Verified on a real Kubernetes cluster (Docker Desktop, Kubernetes 1.36, real Traefik 3.7.16 installed with its Helm chart), both ways:
- Outside (kubeconfig, API server proxy): Services discovered, readiness from EndpointSlices, specs fetched through the API server proxy;
the derived public URLs came from a real
Ingress(https://orders.k8s.example.test) and a realIngressRouteand match routes Traefik really serves; Try-it for those URLs returned the Services' data; scaling a Deployment to zero showed down within 1 s of the command and back up on scale-up; removing the label removed the service;--kube-access=autochose the API server proxy;--namespacerestricted the watch. - Inside (image deployed with
deploy/kubernetes): non-root, read-only filesystem, no capabilities;access=serviceauto-detected, in-cluster DNS, the RBAC is sufficient; in Chrome, through Traefik, the UI contacted only its own host and Try-it worked for both the Ingress- and the IngressRoute-derived URL. - With incomplete RBAC (no ingresses/ingressroutes) it warns after 30 s and keeps working without derived URLs.
- Bugs found there and fixed:
USER nonroot(a name) is rejected by Kubernetes'runAsNonRoot; the Dockerfile now uses the numeric uid 65532.
Verified on a second, different cluster: k3s v1.35.5 created with k3d (a different distribution from Docker Desktop's kind-based one), with its own
bundled Traefik 3.6.13. Same results inside and outside the cluster (derived URLs from an Ingress and an IngressRoute, Try-it, readiness, JSON logs from the pod),
and the Kubernetes integration test passes there too (KUBECONFIG=<k3d kubeconfig> SWAGGER_AGGREGATOR_IT_KUBE_CONTEXT=k3d-<name> go test -tags=integration ...).
Verified with Traefik and an ACME resolver: Traefik 3.7.16 with certificatesresolvers.le.acme pointed at Pebble (Let's Encrypt's test CA; real ACME protocol with
HTTP-01 validation): the certificate was issued by "Pebble Intermediate CA" (a router without a resolver gets Traefik's default certificate). A router that has only
tls.certresolver (no tls=true) derives https://... correctly and Try-it works. Real Let's Encrypt was not used (it needs public DNS and ports); Traefik's ACME
handling is not our code, only the label/spec.tls detection is.
Verified with PathPrefix and StripPrefix routers on real Traefik, on both Docker (labels) and Kubernetes (IngressRoute + Middleware), comparing what the public URL returns with
what the aggregator's Try-it returns for the same URL:
- Traefik strips
/strip-> matches with the default (strip-prefixtrue). - Traefik keeps
/api(the workload serves/api/items) -> did not match with the default; this was a real gap, now handled bystrip-prefix: "false", and matches. - A route with
PathPrefixand noserver-url/annotation is deliberately not derived (the aggregator cannot know whether a middleware strips the prefix); the spec then uses the same-origin proxy path and Try-it works.
Not verified: Traefik's file provider, Gateway API (HTTPRoute) and Traefik v2 CRDs (traefik.containo.us) which are not implemented, a managed cloud cluster
(EKS/GKE/AKS), the Docker integration test on a native Linux daemon (it then uses container IPs; on Docker Desktop it uses published ports).
Docker Desktop and the Kubernetes cluster. Docker Desktop's cluster is a kind cluster: its node is a Docker container (desktop-control-plane) that Docker Desktop hides from
docker ps -a and from the Containers view, and the pods are containerd containers inside that node, so they are never Docker containers. Use kubectl to see them.
A k3d cluster, by contrast, shows its nodes as ordinary containers in docker ps (k3d-<name>-server-0).
- PrimeVue 5 needs a licence key (Community, renewed yearly); without it a badge shows on the PrimeVue pages. See UI stack and licences.
- The built-in authentication is single-node SQLite without two-factor login or self-service password reset; the MCP token is one shared secret. See Authentication and MCP.
moby/moby/clientis v0.x (v0.6.2 is the latest release, checked 2026-10-24); its API may still change between releases. Pinned ingo.mod.- Compose replicas (
scale) are one service: it is up while any replica is up and is documented by one healthy replica (the spec's "Try it" path points at that replica, and moves with it). Replicas are recognised by the compose project + service labels; unrelated containers that merely share a name get a-<id>suffix, which is only stable while the container runs. servers(also on path items and operations) and top-levelhost+basePath+schemesare rewritten; callback/webhookserversare left alone.- Spec parsing re-serializes the document (keys are sorted, YAML is converted to JSON).
$refs to external files are not resolved. Unchanged specs (identical bytes) are not re-parsed on later probes. - A service that never delivered a spec has no document in the reference view (Scalar needs a spec); it is listed with its error in the sidebar and on
/status. - The proxies are plain forwards to the container (HTTPS only with
scheme=https; redirects are passed to the browser as is). Aserver-urlwith a path is treated like aStripPrefix(the workload sees the path without it) unless you setstrip-prefix: "false". Routes that rewrite the path in other ways (e.g.ReplacePathRegex) are not understood. - Kubernetes: services in different namespaces are named
<name>.<namespace>unless.nameis set; ingress-derived URLs need exactly one host-based route with the root path per Service;ExternalNameServices and Services without ready endpoints are shown as down. - Last-known specs are kept only in memory: a stopped container that exists when the API restarts is listed as down without a spec.
swagger-aggregator-webmust run in front of the API (or any reverse proxy that serves/fromweb/distand/apifrom the API). Under a sub-path (--base-path) the proxy must forward that prefix toswagger-aggregator-web, which strips it itself.typescriptis aliased totypescript-native-bridge(a tsgo-backed TypeScript fork from thejohnsoncodehk/typescript-native-bridgerepo, pinned to an exact version):vue-tsc3.3.13 does not work with TypeScript 7 yet (vuejs/language-tools#6124). Drop the alias once avue-tscrelease supports TypeScript 7. It is a third-party package with a native binary, used only forbun run typecheck(not part of the Docker image); the typecheck needs Node (it fails underbun --bun).
Apache License 2.0, see also NOTICE. It covers the code of this repository. Third-party software keeps its own licence; in particular the web UI is built with PrimeVue 5 under the PrimeUI licence, which needs its own key and which this licence does not change (see UI stack and licences). Contributions are accepted under the same licence.