Skip to content

Commit 383d171

Browse files
committed
fix(api): self-host Swagger UI; add Dependabot for Go and actions
The /v1/docs page pulled the Swagger UI shell (CSS + JS bundle) from jsdelivr — the last external fetch in the panel. It renders half-drawn where that CDN is blocked, which is the networks this panel serves, and tells a third party who opened the docs. Vendor swagger-ui-dist 5.17.14 into the binary and serve it from our own origin, matching what the decoys and the subscription page already do. Dependabot bumps the go.mod toolchain and the CI actions weekly, so a stdlib CVE (this run's govulncheck failure) is patched by a one-line PR instead of a red main; govulncheck stays the backstop between runs.
1 parent 609c4af commit 383d171

6 files changed

Lines changed: 122 additions & 5 deletions

File tree

.github/dependabot.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Keeps the Go toolchain and the CI actions patched without waiting for someone to
2+
# notice a failing govulncheck. The `go` directive in go.mod is what CI resolves its
3+
# toolchain from (go-version-file: go.mod), so a Dependabot bump of it is exactly the
4+
# one-line fix a stdlib CVE needs — govulncheck in CI stays the backstop for anything
5+
# that lands between weekly runs.
6+
version: 2
7+
updates:
8+
- package-ecosystem: gomod
9+
directory: /
10+
schedule:
11+
interval: weekly
12+
# One PR for the toolchain + all module bumps of a kind, rather than a swarm.
13+
groups:
14+
go-dependencies:
15+
patterns:
16+
- "*"
17+
commit-message:
18+
prefix: "chore(deps)"
19+
open-pull-requests-limit: 5
20+
21+
- package-ecosystem: github-actions
22+
directory: /
23+
schedule:
24+
interval: weekly
25+
groups:
26+
ci-actions:
27+
patterns:
28+
- "*"
29+
commit-message:
30+
prefix: "chore(ci)"
31+
open-pull-requests-limit: 5

internal/server/api_v1.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ func (rt *Router) apiHandler() http.Handler {
9191
rt.apiRoutes = append(rt.apiRoutes, pattern)
9292
mux.HandleFunc(pattern, h)
9393
}
94+
// The Swagger UI shell, served from our own origin rather than a CDN — the same
95+
// reason the decoys and the subscription page carry their own assets: a jsdelivr
96+
// link renders half a docs page where jsdelivr is blocked, and it tells a third
97+
// party who opened them. Not recorded in apiRoutes: these are static assets, not
98+
// REST resources the OpenAPI document should list.
99+
mux.HandleFunc("GET /v1/swagger-ui.css", rt.swaggerAsset("swagger-ui.css", "text/css"))
100+
mux.HandleFunc("GET /v1/swagger-ui-bundle.js", rt.swaggerAsset("swagger-ui-bundle.js", "text/javascript"))
94101
// The MCP endpoint authenticates from the path (see api_v1_mcp.go), so it is
95102
// mounted before the bearer-authenticated catch-all. Deliberately NOT recorded in
96103
// apiRoutes: it is a JSON-RPC transport rather than a REST resource, so there is

internal/server/openapi.go

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package server
22

33
import (
4+
"embed"
45
"maps"
56
"net/http"
67
"reflect"
@@ -699,25 +700,48 @@ func (rt *Router) apiOpenAPI(w http.ResponseWriter, r *http.Request) {
699700
writeJSON(w, http.StatusOK, buildOpenAPI(apiBaseURL(r, apiPath)))
700701
}
701702

702-
// apiDocs serves a Swagger UI page pointed at the generated spec. The UI shell is
703-
// loaded from a CDN (this page is a developer convenience, reached only by someone
704-
// who already knows the secret API path); the spec it renders is fully local.
703+
// swaggerAssets holds the Swagger UI shell (CSS + JS bundle), vendored so the docs
704+
// page loads entirely from our own origin — see apiDocs.
705+
//
706+
//go:embed swaggerui/swagger-ui.css swaggerui/swagger-ui-bundle.js
707+
var swaggerAssets embed.FS
708+
709+
// apiDocs serves a Swagger UI page pointed at the generated spec. Both the shell and
710+
// the spec are local: a CDN link would render a half-drawn page where that CDN is
711+
// blocked (the networks this panel serves), and leak to a third party who opened it.
712+
// The links are relative to /v1/docs, so they resolve to /v1/swagger-ui* regardless
713+
// of the secret API path in front.
705714
func (rt *Router) apiDocs(w http.ResponseWriter, _ *http.Request) {
706715
w.Header().Set("Content-Type", "text/html; charset=utf-8")
707716
_, _ = w.Write([]byte(swaggerHTML))
708717
}
709718

719+
// swaggerAsset serves one embedded Swagger UI file with a long cache lifetime (the
720+
// bundle is versioned by its bytes and changes only on a rebuild).
721+
func (rt *Router) swaggerAsset(name, contentType string) http.HandlerFunc {
722+
body, err := swaggerAssets.ReadFile("swaggerui/" + name)
723+
return func(w http.ResponseWriter, r *http.Request) {
724+
if err != nil {
725+
http.Error(w, "asset unavailable", http.StatusInternalServerError)
726+
return
727+
}
728+
w.Header().Set("Content-Type", contentType+"; charset=utf-8")
729+
w.Header().Set("Cache-Control", "public, max-age=86400")
730+
_, _ = w.Write(body)
731+
}
732+
}
733+
710734
const swaggerHTML = `<!doctype html>
711735
<html lang="en">
712736
<head>
713737
<meta charset="utf-8">
714738
<meta name="viewport" content="width=device-width, initial-scale=1">
715739
<title>RosPanel API</title>
716-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
740+
<link rel="stylesheet" href="swagger-ui.css">
717741
</head>
718742
<body>
719743
<div id="swagger-ui"></div>
720-
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
744+
<script src="swagger-ui-bundle.js"></script>
721745
<script>
722746
window.ui = SwaggerUIBundle({
723747
url: "openapi.json",

internal/server/swagger_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package server
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// The docs page and its shell must load entirely from our own origin — a CDN link
11+
// renders a half-drawn page where that CDN is blocked and leaks who opened it, the
12+
// same rule the decoys and the subscription page already follow.
13+
func TestSwaggerUISelfHosted(t *testing.T) {
14+
h, _, st := nodeAPITestServer(t)
15+
base, _ := apiFixture(t, h, st)
16+
17+
// The docs page is key-free and references only relative assets.
18+
rec := httptest.NewRecorder()
19+
req := httptest.NewRequest(http.MethodGet, base+"/v1/docs", nil)
20+
req.RemoteAddr = testClientIP + ":40000"
21+
h.ServeHTTP(rec, req)
22+
if rec.Code != http.StatusOK {
23+
t.Fatalf("docs page status %d", rec.Code)
24+
}
25+
body := rec.Body.String()
26+
if strings.Contains(body, "jsdelivr") || strings.Contains(body, "http://") ||
27+
strings.Contains(body, "https://") {
28+
t.Errorf("docs page still references an external origin:\n%s", body)
29+
}
30+
31+
// Both assets are served locally with a sane content type.
32+
for _, a := range []struct{ path, ct string }{
33+
{"/v1/swagger-ui.css", "text/css"},
34+
{"/v1/swagger-ui-bundle.js", "text/javascript"},
35+
} {
36+
rec := httptest.NewRecorder()
37+
req := httptest.NewRequest(http.MethodGet, base+a.path, nil)
38+
req.RemoteAddr = testClientIP + ":40000"
39+
h.ServeHTTP(rec, req)
40+
if rec.Code != http.StatusOK {
41+
t.Errorf("%s status %d", a.path, rec.Code)
42+
}
43+
if !strings.HasPrefix(rec.Header().Get("Content-Type"), a.ct) {
44+
t.Errorf("%s content-type %q, want %s", a.path, rec.Header().Get("Content-Type"), a.ct)
45+
}
46+
if rec.Body.Len() < 1000 {
47+
t.Errorf("%s suspiciously small (%d bytes) — asset missing?", a.path, rec.Body.Len())
48+
}
49+
}
50+
}

internal/server/swaggerui/swagger-ui-bundle.js

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/server/swaggerui/swagger-ui.css

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)