mirror of
https://github.com/vee1e/subidx.git
synced 2026-09-01 17:57:13 +00:00
decouple the dashboard from the server binary
Move the Svelte dashboard out of internal/web into its own top-level frontend/ so it can be built and hosted independently, and make the subidx binary API-only: drop the embedded dist and the catch-all / handler, so unknown paths return a plain 404. The frontend already knew how to live apart (VITE_API_BASE, -cors-origins, vercel.json), so only the embed glue is gone. Also wire in Vercel Web Analytics via @vercel/analytics.
This commit is contained in:
parent
faa4e1b58a
commit
41a2828ef0
27 changed files with 85 additions and 247 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@
|
|||
SPEC.md
|
||||
/docs/
|
||||
node_modules/
|
||||
/frontend/dist/
|
||||
|
|
|
|||
8
Makefile
8
Makefile
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
all: build
|
||||
|
||||
# Rebuild the dashboard UI (output is committed at internal/web/dist).
|
||||
# Build the dashboard UI for a separate host (Vercel, Render, or any
|
||||
# static host). Point it at the subidx API with VITE_API_BASE; the
|
||||
# binary no longer embeds or serves the UI.
|
||||
ui:
|
||||
cd internal/web && npm ci && npm run build
|
||||
cd frontend && npm ci && npm run build
|
||||
|
||||
build: go build -o subidx .
|
||||
|
||||
|
|
@ -15,7 +17,7 @@ vet: go vet ./...
|
|||
serve: go run . serve -store ./data -addr 127.0.0.1:8099
|
||||
|
||||
dev:
|
||||
cd internal/web && SUBIDX_API=$${SUBIDX_API:-http://127.0.0.1:8080} npm run dev
|
||||
cd frontend && SUBIDX_API=$${SUBIDX_API:-http://127.0.0.1:8080} npm run dev
|
||||
|
||||
clean:
|
||||
rm -f subidx
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# subidx
|
||||
|
||||
Self-hosted passive subdomain enumeration from Certificate Transparency. subidx tails public CT logs (the records of every HTTPS certificate issued), indexes every hostname it sees, and serves them through a search API and dashboard you control. Think crt.name, but the data lives on your machine.
|
||||
Self-hosted passive subdomain enumeration from Certificate Transparency. subidx tails public CT logs (the records of every HTTPS certificate issued), indexes every hostname it sees, and serves them through a search API you control. Think crt.name, but the data lives on your machine.
|
||||
|
||||

|
||||
|
||||
|
|
@ -16,7 +16,7 @@ go build -o subidx .
|
|||
curl "http://localhost:8099/v1/search?apex=letsencrypt.org"
|
||||
```
|
||||
|
||||
or open `http://localhost:8099/` for the dashboard: search any apex, filter and sort results instantly, toggle first-seen dates, export txt/csv, and watch a live feed of newly collected names. The UI is compiled into the binary; there is nothing extra to deploy.
|
||||
The binary is API-only. The dashboard is a separate SPA in `frontend/` (Vite + Svelte): search any apex, filter and sort results instantly, toggle first-seen dates, export txt/csv, and watch a live feed of newly collected names. Deploy it to any static host (Vercel, Render, or your own), pointing it at the API with `VITE_API_BASE` and allowing its origin with `-cors-origins`. For local work, `make dev` runs it against a local `subidx serve`.
|
||||
|
||||
## What gets stored
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ Tools like [subfinder](https://github.com/projectdiscovery/subfinder) query othe
|
|||
- **No keys, no quotas.** Your only dependencies are the log lists themselves.
|
||||
- **Verified provenance.** Log keys are pinned against the log lists, tree heads are cryptographically verified, and each fetched batch must pass an RFC 6962 inclusion-proof spot check before it is stored.
|
||||
- **Historical drains.** Years of history from retired logs can be backfilled, if you have the terabytes.
|
||||
- **An API and dashboard, not just a CLI.** Pipe results into your tooling or browse them.
|
||||
- **An API and dashboard, not just a CLI.** Pipe results into your tooling, or browse them in the separately hosted UI.
|
||||
|
||||
Limitations: CT only sees names that were issued a certificate. Names that live only in DNS are invisible here, which is why subidx complements rather than replaces broader-source tools.
|
||||
|
||||
|
|
@ -46,14 +46,14 @@ Limitations: CT only sees names that were issued a certificate. Names that live
|
|||
3. **Parsing.** Entries are decoded just far enough to read the SAN list; everything else is discarded.
|
||||
4. **Normalizing.** Names are lowercased, wildcards and trailing dots stripped, reserved names rejected, and each name assigned to its registered domain (apex) via the Public Suffix List.
|
||||
5. **Storing.** A single writer inserts into an embedded Pebble store. Duplicates keep only the earliest date; nothing is deleted.
|
||||
6. **Serving.** An HTTP API with rate limiting and health checks, plus the embedded dashboard.
|
||||
6. **Serving.** An HTTP API with rate limiting and health checks. The dashboard is a separate frontend built from `frontend/` and served by any static host.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `tail` | Watch CT logs and store names. Runs until you stop it. |
|
||||
| `serve` | Serve the API and dashboard. Add `-no-tail` to serve without collecting. |
|
||||
| `serve` | Serve the read-only search API and health endpoints. Add `-no-tail` to serve without collecting. |
|
||||
| `stats` | Print total records and the top 10 domains. `-recount` fixes drifted counters. |
|
||||
| `version` | Print the version. |
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
"": {
|
||||
"name": "subidx-ui",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@vercel/analytics": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
|
|
@ -461,7 +464,7 @@
|
|||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
|
|
@ -472,7 +475,7 @@
|
|||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
|
|
@ -483,7 +486,7 @@
|
|||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
|
@ -493,14 +496,14 @@
|
|||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
|
|
@ -920,7 +923,7 @@
|
|||
"version": "1.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz",
|
||||
"integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"acorn": "^8.9.0"
|
||||
|
|
@ -979,21 +982,63 @@
|
|||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vercel/analytics": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz",
|
||||
"integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@remix-run/react": "^2",
|
||||
"@sveltejs/kit": "^1 || ^2",
|
||||
"next": ">= 13",
|
||||
"nuxt": ">= 3",
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"svelte": ">= 4",
|
||||
"vue": "^3",
|
||||
"vue-router": "^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@remix-run/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@sveltejs/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"next": {
|
||||
"optional": true
|
||||
},
|
||||
"nuxt": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"svelte": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
},
|
||||
"vue-router": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
|
|
@ -1006,7 +1051,7 @@
|
|||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -1016,7 +1061,7 @@
|
|||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -1042,7 +1087,7 @@
|
|||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
|
|
@ -1062,7 +1107,7 @@
|
|||
"version": "5.9.1",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.1.tgz",
|
||||
"integrity": "sha512-+17vil3EVQRzvtDJSFuTWEb8XJRvXqAiV3qZyQWD398QeXUa6CxsUyMdD1fxzEhUrd4FojitFz7lhIHBTlV4fw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
|
|
@ -1111,14 +1156,14 @@
|
|||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esrap": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.6.tgz",
|
||||
"integrity": "sha512-yc0OC12UjPqLoc+fe+v5GNs4TOjAigUw3sTikfC+xeBPGUw7gDRz3DtYaqEhxyMVJojcSWJw7jT0QWR+CbuE/A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
|
|
@ -1169,7 +1214,7 @@
|
|||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.6"
|
||||
|
|
@ -1179,14 +1224,14 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
|
|
@ -1371,7 +1416,7 @@
|
|||
"version": "5.56.10",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.10.tgz",
|
||||
"integrity": "sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
|
|
@ -1550,7 +1595,7 @@
|
|||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
|
@ -14,5 +14,8 @@
|
|||
"svelte-check": "^4.1.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vercel/analytics": "^2.0.1"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 572 B After Width: | Height: | Size: 572 B |
|
|
@ -1,7 +1,10 @@
|
|||
import { mount } from 'svelte'
|
||||
import { inject } from '@vercel/analytics'
|
||||
import './app.css'
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = mount(App, { target: document.getElementById('app')! })
|
||||
|
||||
inject()
|
||||
|
||||
export default app
|
||||
|
|
@ -16,7 +16,6 @@ import (
|
|||
|
||||
"subidx/internal/apex"
|
||||
"subidx/internal/store"
|
||||
"subidx/internal/web"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
|
|
@ -80,7 +79,6 @@ func (s *Server) Handler() http.Handler {
|
|||
mux.HandleFunc("/v1/feed", s.handleFeed)
|
||||
mux.HandleFunc("/healthz", s.handleHealth)
|
||||
mux.HandleFunc("/readyz", s.handleReady)
|
||||
mux.Handle("/", web.Handler())
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.hostAllowed(r.Host) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
|
|
|
|||
|
|
@ -281,10 +281,10 @@ func TestHeadNotAllowed(t *testing.T) {
|
|||
if po.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("post code = %d", po.Code)
|
||||
}
|
||||
// Unknown paths serve the embedded UI shell instead of a bare 404.
|
||||
// Unknown paths are a bare 404; the UI is a separately hosted app now.
|
||||
nf := do(t, s, http.MethodGet, "/nope")
|
||||
if nf.Code != http.StatusOK || !strings.Contains(nf.Header().Get("Content-Type"), "text/html") {
|
||||
t.Errorf("ui shell code = %d ct = %q", nf.Code, nf.Header().Get("Content-Type"))
|
||||
if nf.Code != http.StatusNotFound {
|
||||
t.Errorf("unknown path code = %d, want 404", nf.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
10
internal/web/dist/assets/index-B5egPO6h.js
vendored
10
internal/web/dist/assets/index-B5egPO6h.js
vendored
File diff suppressed because one or more lines are too long
1
internal/web/dist/assets/index-LEhbGqLZ.css
vendored
1
internal/web/dist/assets/index-LEhbGqLZ.css
vendored
File diff suppressed because one or more lines are too long
16
internal/web/dist/index.html
vendored
16
internal/web/dist/index.html
vendored
|
|
@ -1,16 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="description" content="subidx: self-hosted passive subdomain index from Certificate Transparency logs" />
|
||||
<title>subidx</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<script type="module" crossorigin src="/assets/index-B5egPO6h.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-LEhbGqLZ.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#12151a"/>
|
||||
<path d="M9 8v16" stroke="#e2a54a" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="9" cy="8" r="3" fill="#e2a54a"/>
|
||||
<path d="M9 14h7a3 3 0 0 1 3 3v3" stroke="#5b6472" stroke-width="2.5" stroke-linecap="round" fill="none"/>
|
||||
<path d="M9 21h11a3 3 0 0 1 3 3v0" stroke="#5b6472" stroke-width="2.5" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="19" cy="20" r="2.4" fill="#8a919d"/>
|
||||
<circle cx="23" cy="24" r="2.4" fill="#8a919d"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 572 B |
|
|
@ -1,65 +0,0 @@
|
|||
// Package web serves the dashboard UI embedded from ./dist (the Vite
|
||||
// build output). Assets are immutable-cached by content hash; the HTML
|
||||
// shell is never cached and ships a strict CSP.
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var dist embed.FS
|
||||
|
||||
const csp = "default-src 'none'; script-src 'self'; style-src 'self'; " +
|
||||
"img-src 'self' data:; connect-src 'self'; font-src 'self'; " +
|
||||
"base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
|
||||
|
||||
// Handler returns a handler serving the built UI. Paths that look like
|
||||
// API routes but matched nothing fall through to a plain 404; anything
|
||||
// else unknown serves the HTML shell so the app owns its own states.
|
||||
func Handler() http.Handler {
|
||||
files, err := fs.Sub(dist, "dist")
|
||||
if err != nil {
|
||||
panic("web: embedded dist missing: " + err.Error())
|
||||
}
|
||||
fileServer := http.FileServerFS(files)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
w.Write([]byte("Method Not Allowed\n"))
|
||||
return
|
||||
}
|
||||
|
||||
p := path.Clean(r.URL.Path)
|
||||
if strings.HasPrefix(p, "/v1/") || p == "/v1" || p == "/healthz" || p == "/readyz" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(p, "/")
|
||||
isShell := name == "" || name == "index.html"
|
||||
if !isShell {
|
||||
if _, err := fs.Stat(files, name); err != nil {
|
||||
name = "index.html"
|
||||
isShell = true
|
||||
}
|
||||
}
|
||||
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
if isShell {
|
||||
h.Set("Content-Security-Policy", csp)
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
r.URL.Path = "/"
|
||||
} else {
|
||||
h.Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
req.Host = "localhost"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestShellHeaders(t *testing.T) {
|
||||
rec := get(t, Handler(), "/")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
t.Errorf("content-type = %q", ct)
|
||||
}
|
||||
csp := rec.Header().Get("Content-Security-Policy")
|
||||
if !strings.Contains(csp, "default-src 'none'") || !strings.Contains(csp, "script-src 'self'") {
|
||||
t.Errorf("csp = %q", csp)
|
||||
}
|
||||
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Error("missing nosniff")
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache" {
|
||||
t.Errorf("cache-control = %q", cc)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `id="app"`) {
|
||||
t.Error("body is not the app shell")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetsImmutable(t *testing.T) {
|
||||
files, err := fs.Sub(dist, "dist")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asset := ""
|
||||
err = fs.WalkDir(files, ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if asset == "" && !d.IsDir() && strings.HasPrefix(d.Name(), "index-") &&
|
||||
(strings.HasSuffix(p, ".js") || strings.HasSuffix(p, ".css")) {
|
||||
asset = p
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asset == "" {
|
||||
t.Fatal("no built assets found under dist/assets")
|
||||
}
|
||||
|
||||
h := Handler()
|
||||
rec := get(t, h, "/"+asset)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
|
||||
t.Errorf("cache-control = %q", cc)
|
||||
}
|
||||
if strings.HasSuffix(asset, ".css") {
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/css") {
|
||||
t.Errorf("css content-type = %q", ct)
|
||||
}
|
||||
} else {
|
||||
// Go resolves .js via the OS mime database, which differs between
|
||||
// platforms; both spellings are fine for browsers.
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
|
||||
t.Errorf("js content-type = %q", ct)
|
||||
}
|
||||
}
|
||||
if csp := rec.Header().Get("Content-Security-Policy"); csp != "" {
|
||||
t.Errorf("csp on hashed asset: %q", csp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackAndReservedPaths(t *testing.T) {
|
||||
h := Handler()
|
||||
|
||||
fb := get(t, h, "/some/deep/link")
|
||||
if fb.Code != http.StatusOK || !strings.Contains(fb.Body.String(), `id="app"`) {
|
||||
t.Errorf("fallback code = %d", fb.Code)
|
||||
}
|
||||
|
||||
api := get(t, h, "/v1/nope")
|
||||
if api.Code != http.StatusNotFound {
|
||||
t.Errorf("/v1/nope code = %d, want 404", api.Code)
|
||||
}
|
||||
hz := get(t, h, "/healthz")
|
||||
if hz.Code != http.StatusNotFound {
|
||||
t.Errorf("/healthz code = %d, want 404 (server mux owns it)", hz.Code)
|
||||
}
|
||||
|
||||
post := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, post)
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("post / code = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue