|
| 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/<width></code>). Level 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/<index></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 => ({ "&": "&", "<": "<", ">": ">" }[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> |
0 commit comments