Skip to content

Commit 8194ba5

Browse files
committed
Add embedded log-browser UI on the monitoring endpoint
Serve a self-contained index.html (pure HTML/CSS/JS, no dependencies, go:embed) at the log root. It links to the checkpoint, shows the tree size/height parsed from the checkpoint, lists the level-0 hash tiles and entry tiles implied by the tree size, and offers a manual tile/entry fetcher that decodes node hashes and length-prefixed entries. The tile server serves it at GET /{$}; the monitoring root redirects to the log's UI. All UI fetches are relative so it works under any log-number prefix.
1 parent 8ed16bf commit 8194ba5

3 files changed

Lines changed: 328 additions & 0 deletions

File tree

cmd/cactus/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
383383
// tile/checkpoint/landmark routes under "/<log number>/".
384384
logPrefix := "/" + strconv.Itoa(int(cfg.Log.Number))
385385
monMux.Handle(logPrefix+"/", http.StripPrefix(logPrefix, tileSrv.Handler()))
386+
// The monitoring base is the CA prefix; redirect it to the (single)
387+
// log's browser UI so the bare root lands somewhere useful.
388+
monMux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
389+
http.Redirect(w, r, logPrefix+"/", http.StatusFound)
390+
})
386391
monitoringHTTP := &http.Server{
387392
Addr: cfg.Monitoring.Listen,
388393
Handler: logging.Middleware(logger)(monMux),

tile/index.html

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
<!DOCTYPE html>
2+
<!--
3+
cactus issuance-log browser. Served at the log root (e.g. /<log number>/)
4+
by tile/server.go via go:embed. Pure HTML/CSS/JS, no dependencies. All
5+
fetches use relative URLs, so the page works under any log-number prefix.
6+
-->
7+
<html lang="en">
8+
<head>
9+
<meta charset="utf-8">
10+
<meta name="viewport" content="width=device-width, initial-scale=1">
11+
<title>cactus issuance log</title>
12+
<style>
13+
:root {
14+
--bg: #0f1419; --panel: #1a2029; --fg: #d6dde6; --muted: #8794a3;
15+
--accent: #5cc8a8; --accent2: #7aa2f7; --border: #2a323d; --warn: #e0af68;
16+
}
17+
* { box-sizing: border-box; }
18+
body {
19+
margin: 0; padding: 1.5rem; background: var(--bg); color: var(--fg);
20+
font: 14px/1.5 ui-sans-serif, system-ui, sans-serif;
21+
}
22+
h1 { font-size: 1.3rem; margin: 0 0 .25rem; }
23+
h2 { font-size: 1rem; margin: 0 0 .75rem; color: var(--accent); }
24+
a { color: var(--accent2); text-decoration: none; }
25+
a:hover { text-decoration: underline; }
26+
.wrap { max-width: 960px; margin: 0 auto; }
27+
.sub { color: var(--muted); margin: 0 0 1.5rem; word-break: break-all; }
28+
.panel {
29+
background: var(--panel); border: 1px solid var(--border);
30+
border-radius: 8px; padding: 1rem 1.25rem; margin-bottom: 1.25rem;
31+
}
32+
.grid { display: grid; grid-template-columns: max-content 1fr; gap: .35rem 1rem; }
33+
.grid dt { color: var(--muted); }
34+
.grid dd { margin: 0; word-break: break-all; }
35+
.big { font-size: 1.8rem; font-weight: 600; color: var(--accent); }
36+
code, .mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
37+
button, input, select {
38+
font: inherit; background: #11161d; color: var(--fg);
39+
border: 1px solid var(--border); border-radius: 6px; padding: .4rem .6rem;
40+
}
41+
button { cursor: pointer; background: #232c38; }
42+
button:hover { border-color: var(--accent); }
43+
.row { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
44+
.row label { color: var(--muted); }
45+
input[type=number] { width: 7rem; }
46+
.tiles { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .75rem; }
47+
.tile {
48+
font-family: ui-monospace, monospace; font-size: 12px; padding: .3rem .5rem;
49+
border: 1px solid var(--border); border-radius: 6px; background: #11161d;
50+
cursor: pointer; color: var(--accent2);
51+
}
52+
.tile:hover { border-color: var(--accent2); }
53+
.tile.partial { color: var(--warn); border-style: dashed; }
54+
.out {
55+
margin-top: .9rem; background: #0b0f14; border: 1px solid var(--border);
56+
border-radius: 6px; padding: .75rem; overflow-x: auto;
57+
font-family: ui-monospace, monospace; font-size: 12px; white-space: pre;
58+
}
59+
.hash { display: grid; grid-template-columns: 3.5rem 1fr; gap: .25rem .75rem; }
60+
.hash .i { color: var(--muted); text-align: right; }
61+
.err { color: #f7768e; }
62+
.ok { color: var(--accent); }
63+
.note { color: var(--muted); font-size: 12px; margin-top: .5rem; }
64+
.hint { color: var(--muted); font-size: 12px; }
65+
</style>
66+
</head>
67+
<body>
68+
<div class="wrap">
69+
<h1>cactus issuance log</h1>
70+
<p class="sub" id="origin">loading…</p>
71+
72+
<div class="panel">
73+
<h2>Checkpoint</h2>
74+
<dl class="grid">
75+
<dt>Tree size</dt><dd><span class="big" id="size"></span> <span class="hint">entries</span></dd>
76+
<dt>Tree height</dt><dd id="height"></dd>
77+
<dt>Root hash</dt><dd class="mono" id="root"></dd>
78+
<dt>Resource</dt><dd><a id="cplink" href="checkpoint">checkpoint</a> · <a id="lmlink" href="landmarks">landmarks</a> · <a href="../ca-certificate">ca-certificate</a></dd>
79+
</dl>
80+
<div class="row" style="margin-top:.75rem">
81+
<button id="refresh">Refresh</button>
82+
<span id="cpstatus" class="hint"></span>
83+
</div>
84+
</div>
85+
86+
<div class="panel">
87+
<h2>Tile browser</h2>
88+
<p class="hint">Hash tiles hold up to 256 × 32-byte node hashes; the rightmost
89+
tile at each level may be partial (<code>.p/&lt;width&gt;</code>). Level&nbsp;0 holds
90+
leaf hashes; entry (data) tiles hold the log entries themselves.</p>
91+
92+
<div id="level0"></div>
93+
<div id="entries0"></div>
94+
95+
<div class="row" style="margin-top:1rem">
96+
<label>Level</label>
97+
<select id="mlevel">
98+
<option value="0">0 (leaves)</option>
99+
<option value="1">1</option>
100+
<option value="2">2</option>
101+
<option value="3">3</option>
102+
<option value="entries">entries (data)</option>
103+
</select>
104+
<label>Index</label><input type="number" id="mindex" min="0" value="0">
105+
<label>Width</label><input type="number" id="mwidth" min="1" max="256" placeholder="full (256)">
106+
<button id="mfetch">Fetch tile</button>
107+
</div>
108+
<div id="tileout" class="out" style="display:none"></div>
109+
</div>
110+
111+
<div class="panel">
112+
<h2>Entry viewer</h2>
113+
<div class="row">
114+
<label>Index</label><input type="number" id="eindex" min="0" value="0">
115+
<button id="efetch">Fetch entry</button>
116+
<span class="hint">GET <code>log/v1/entry/&lt;index&gt;</code> — one MerkleTreeCertEntry blob</span>
117+
</div>
118+
<div id="entryout" class="out" style="display:none"></div>
119+
</div>
120+
121+
<p class="note">cactus monitoring read-path · tlog-tiles layout. This is a test
122+
server; see the threat model before trusting anything here.</p>
123+
</div>
124+
125+
<script>
126+
"use strict";
127+
const TILE_W = 256; // 1 << TileHeight(8)
128+
129+
const $ = (id) => document.getElementById(id);
130+
const hex = (bytes) => Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
131+
const esc = (s) => s.replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
132+
133+
// Mirrors tilewriter.formatTileIndex: last 3 digits, then "x"-prefixed
134+
// 3-digit groups for higher-order digits.
135+
function formatTileIndex(n) {
136+
let s = String(n % 1000).padStart(3, "0");
137+
n = Math.floor(n / 1000);
138+
while (n > 0) {
139+
s = "x" + String(n % 1000).padStart(3, "0") + "/" + s;
140+
n = Math.floor(n / 1000);
141+
}
142+
return s;
143+
}
144+
145+
// Build a tile URL relative to the log root. level is a number or "entries".
146+
function tileURL(level, index, width) {
147+
let p = "tile/" + level + "/" + formatTileIndex(index);
148+
if (width && width !== TILE_W) p += ".p/" + width;
149+
return p;
150+
}
151+
152+
let treeSize = 0;
153+
154+
async function loadCheckpoint() {
155+
$("cpstatus").textContent = "fetching…";
156+
$("cpstatus").className = "hint";
157+
try {
158+
const r = await fetch("checkpoint", { cache: "no-store" });
159+
if (r.status === 503) {
160+
$("origin").textContent = "(no checkpoint yet — the log is empty)";
161+
$("cpstatus").textContent = "no checkpoint yet";
162+
return;
163+
}
164+
if (!r.ok) throw new Error("HTTP " + r.status);
165+
const text = await r.text();
166+
// signed-note body: origin \n size \n base64(root) \n (then blank + sig lines)
167+
const lines = text.split("\n");
168+
const origin = lines[0] || "";
169+
treeSize = parseInt(lines[1] || "0", 10);
170+
const root = lines[2] || "";
171+
$("origin").textContent = origin;
172+
$("size").textContent = treeSize.toLocaleString();
173+
$("height").textContent = treeSize === 0 ? "0" : Math.ceil(Math.log2(treeSize)) + " levels";
174+
$("root").textContent = root;
175+
$("cpstatus").innerHTML = '<span class="ok">updated ' + new Date().toLocaleTimeString() + "</span>";
176+
renderTileLists();
177+
} catch (e) {
178+
$("cpstatus").innerHTML = '<span class="err">checkpoint error: ' + esc(String(e.message)) + "</span>";
179+
}
180+
}
181+
182+
function tileChip(level, index, width) {
183+
const partial = width !== TILE_W;
184+
const el = document.createElement("span");
185+
el.className = "tile" + (partial ? " partial" : "");
186+
el.textContent = (level === "entries" ? "e" : level) + "/" + index + (partial ? " (" + width + ")" : "");
187+
el.title = tileURL(level, index, width);
188+
el.onclick = () => fetchTile(level, index, partial ? width : null);
189+
return el;
190+
}
191+
192+
// Render the level-0 hash tiles and entry tiles implied by the tree size.
193+
function renderTileLists() {
194+
for (const [containerId, level, label] of [
195+
["level0", "0", "Level-0 hash tiles"],
196+
["entries0", "entries", "Entry (data) tiles"],
197+
]) {
198+
const box = $(containerId);
199+
box.innerHTML = "";
200+
if (treeSize === 0) continue;
201+
const h = document.createElement("div");
202+
h.className = "hint";
203+
h.style.marginTop = ".5rem";
204+
h.textContent = label + ":";
205+
box.appendChild(h);
206+
const tiles = document.createElement("div");
207+
tiles.className = "tiles";
208+
const full = Math.floor(treeSize / TILE_W);
209+
const rem = treeSize % TILE_W;
210+
const MAX = 64;
211+
const total = full + (rem ? 1 : 0);
212+
for (let i = 0; i < Math.min(full, MAX); i++) tiles.appendChild(tileChip(level, i, TILE_W));
213+
if (rem && full < MAX) tiles.appendChild(tileChip(level, full, rem));
214+
if (total > MAX) {
215+
const more = document.createElement("span");
216+
more.className = "hint";
217+
more.textContent = "+" + (total - MAX) + " more (use manual fetch)";
218+
tiles.appendChild(more);
219+
}
220+
box.appendChild(tiles);
221+
}
222+
}
223+
224+
async function fetchTile(level, index, width) {
225+
const out = $("tileout");
226+
out.style.display = "block";
227+
const url = tileURL(level, index, width);
228+
out.textContent = "GET " + url + " …";
229+
try {
230+
const r = await fetch(url, { cache: "no-store" });
231+
if (!r.ok) { out.innerHTML = '<span class="err">GET ' + esc(url) + " → HTTP " + r.status + "</span>"; return; }
232+
const buf = new Uint8Array(await r.arrayBuffer());
233+
if (level === "entries") renderEntriesTile(out, url, buf);
234+
else renderHashTile(out, url, buf);
235+
} catch (e) {
236+
out.innerHTML = '<span class="err">' + esc(String(e.message)) + "</span>";
237+
}
238+
}
239+
240+
function renderHashTile(out, url, buf) {
241+
const n = Math.floor(buf.length / 32);
242+
let html = '<span class="ok">GET ' + esc(url) + "</span>\n" +
243+
buf.length + " bytes · " + n + " node hash" + (n === 1 ? "" : "es") + "\n\n";
244+
html += '<div class="hash">';
245+
for (let i = 0; i < n; i++) {
246+
html += '<span class="i">' + i + '</span><span>' + hex(buf.subarray(i * 32, i * 32 + 32)) + "</span>";
247+
}
248+
html += "</div>";
249+
if (buf.length % 32 !== 0) html += '\n<span class="err">note: length not a multiple of 32</span>';
250+
out.innerHTML = html;
251+
}
252+
253+
// Entry (data) tiles are uint16-big-endian length-prefixed entries.
254+
function renderEntriesTile(out, url, buf) {
255+
let html = '<span class="ok">GET ' + esc(url) + "</span>\n" + buf.length + " bytes\n\n";
256+
let pos = 0, i = 0;
257+
try {
258+
while (pos + 2 <= buf.length) {
259+
const len = (buf[pos] << 8) | buf[pos + 1];
260+
pos += 2;
261+
const body = buf.subarray(pos, pos + len);
262+
pos += len;
263+
const preview = hex(body.subarray(0, 32));
264+
html += "entry " + i + ": " + len + " bytes " + preview + (len > 32 ? "…" : "") + "\n";
265+
i++;
266+
}
267+
html = html.replace("\n\n", "\n" + i + " entr" + (i === 1 ? "y" : "ies") + "\n\n");
268+
} catch (e) {
269+
html += '<span class="err">decode error: ' + esc(String(e.message)) + "</span>";
270+
}
271+
out.innerHTML = html;
272+
}
273+
274+
async function fetchEntry() {
275+
const idx = parseInt($("eindex").value, 10) || 0;
276+
const out = $("entryout");
277+
out.style.display = "block";
278+
const url = "log/v1/entry/" + idx;
279+
out.textContent = "GET " + url + " …";
280+
try {
281+
const r = await fetch(url, { cache: "no-store" });
282+
if (!r.ok) { out.innerHTML = '<span class="err">GET ' + esc(url) + " → HTTP " + r.status + "</span>"; return; }
283+
const buf = new Uint8Array(await r.arrayBuffer());
284+
out.innerHTML = '<span class="ok">GET ' + esc(url) + "</span>\n" +
285+
buf.length + " bytes (MerkleTreeCertEntry)\n\n" + hex(buf).replace(/(.{64})/g, "$1\n");
286+
} catch (e) {
287+
out.innerHTML = '<span class="err">' + esc(String(e.message)) + "</span>";
288+
}
289+
}
290+
291+
// Hide the landmarks link if the endpoint isn't enabled.
292+
fetch("landmarks", { method: "HEAD" }).then(r => {
293+
if (!r.ok) $("lmlink").style.display = "none";
294+
}).catch(() => { $("lmlink").style.display = "none"; });
295+
296+
$("refresh").onclick = loadCheckpoint;
297+
$("mfetch").onclick = () => {
298+
const level = $("mlevel").value;
299+
const index = parseInt($("mindex").value, 10) || 0;
300+
const wraw = parseInt($("mwidth").value, 10);
301+
fetchTile(level, index, Number.isFinite(wraw) ? wraw : null);
302+
};
303+
$("efetch").onclick = fetchEntry;
304+
loadCheckpoint();
305+
</script>
306+
</body>
307+
</html>

tile/server.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
package tile
99

1010
import (
11+
_ "embed"
1112
"errors"
1213
"io/fs"
1314
"net/http"
@@ -20,6 +21,13 @@ import (
2021
"github.com/letsencrypt/cactus/storage"
2122
)
2223

24+
// indexHTML is the self-contained browser UI served at the log root. It
25+
// is pure HTML/CSS/JS with no dependencies and uses only relative URLs,
26+
// so it works under any log-number prefix.
27+
//
28+
//go:embed index.html
29+
var indexHTML []byte
30+
2331
// Server is the read-path HTTP handler.
2432
type Server struct {
2533
log *log.Log
@@ -42,6 +50,7 @@ func (s *Server) WithLandmarks(seq *landmark.Sequence) *Server {
4250

4351
// Handler returns the HTTP handler. Routes:
4452
//
53+
// GET / — browser UI (index.html)
4554
// GET /checkpoint — latest signed note
4655
// GET /tile/<L>/<NNN..> — hash tiles (c2sp tlog-tiles)
4756
// GET /tile/entries/<NNN..> — entry (data) tiles (c2sp tlog-tiles)
@@ -50,6 +59,7 @@ func (s *Server) WithLandmarks(seq *landmark.Sequence) *Server {
5059
// GET /landmarks — §6.3.1 landmark list (only if WithLandmarks)
5160
func (s *Server) Handler() http.Handler {
5261
mux := http.NewServeMux()
62+
mux.HandleFunc("GET /{$}", s.handleIndex)
5363
mux.HandleFunc("GET /checkpoint", s.handleCheckpoint)
5464
mux.HandleFunc("GET /tile/", s.handleTile)
5565
mux.HandleFunc("GET /log/v1/entry/{index}", s.handleEntry)
@@ -61,6 +71,12 @@ func (s *Server) Handler() http.Handler {
6171
return mux
6272
}
6373

74+
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
75+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
76+
w.Header().Set("Cache-Control", "no-cache, max-age=0")
77+
w.Write(indexHTML)
78+
}
79+
6480
func (s *Server) handleCheckpoint(w http.ResponseWriter, r *http.Request) {
6581
cp := s.log.CurrentCheckpoint()
6682
if len(cp.SignedNote) == 0 {

0 commit comments

Comments
 (0)