diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..70e1a30 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +web/index_templ.go linguist-generated diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go index 4cee20c..8ab370d 100644 --- a/cmd/anubis/main.go +++ b/cmd/anubis/main.go @@ -15,6 +15,7 @@ import ( "net/url" "os" "os/signal" + "regexp" "strconv" "sync" "syscall" @@ -23,6 +24,7 @@ import ( "github.com/TecharoHQ/anubis" "github.com/TecharoHQ/anubis/internal" libanubis "github.com/TecharoHQ/anubis/lib" + botPolicy "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/lib/policy/config" "github.com/facebookgo/flagenv" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -44,6 +46,7 @@ var ( target = flag.String("target", "http://localhost:3923", "target to reverse proxy to") healthcheck = flag.Bool("healthcheck", false, "run a health check against Anubis") useRemoteAddress = flag.Bool("use-remote-address", false, "read the client's IP address from the network request, useful for debugging and running Anubis on bare metal") + debugBenchmarkJS = flag.Bool("debug-benchmark-js", false, "respond to every request with a challenge for benchmarking hashrate") ) func keyFromHex(value string) (ed25519.PrivateKey, error) { @@ -187,6 +190,16 @@ func main() { } fmt.Println() + // replace the bot policy rules with a single rule that always benchmarks + if *debugBenchmarkJS { + userAgent := regexp.MustCompile(".") + policy.Bots = []botPolicy.Bot{{ + Name: "", + UserAgent: userAgent, + Action: config.RuleBenchmark, + }} + } + var priv ed25519.PrivateKey if *ed25519PrivateKeyHex == "" { _, priv, err = ed25519.GenerateKey(rand.Reader) @@ -241,6 +254,7 @@ func main() { "target", *target, "version", anubis.Version, "use-remote-address", *useRemoteAddress, + "debug-benchmark-js", *debugBenchmarkJS, ) go func() { diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index 4189e57..39407b4 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Developer documentation has been added to the docs site - Show more errors when some predictable challenge page errors happen ([#150](https://github.com/TecharoHQ/anubis/issues/150)) - Verification page now shows hash rate and a progress bar for completion probability. +- Added the `--debug-benchmark-js` flag for testing proof-of-work performance during development. - Use `TrimSuffix` instead of `TrimRight` on containerbuild ## v1.15.0 diff --git a/lib/anubis.go b/lib/anubis.go index c61b110..939262a 100644 --- a/lib/anubis.go +++ b/lib/anubis.go @@ -241,6 +241,10 @@ func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) { return case config.RuleChallenge: lg.Debug("challenge requested") + case config.RuleBenchmark: + lg.Debug("serving benchmark page") + s.RenderBench(w, r) + return default: s.ClearCookie(w) templ.Handler(web.Base("Oh noes!", web.ErrorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) @@ -334,6 +338,12 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request) { handler.ServeHTTP(w, r) } +func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) { + templ.Handler( + web.Base("Benchmarking Anubis!", web.Bench()), + ).ServeHTTP(w, r) +} + func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) { lg := slog.With("user_agent", r.UserAgent(), "accept_language", r.Header.Get("Accept-Language"), "priority", r.Header.Get("Priority"), "x-forwarded-for", r.Header.Get("X-Forwarded-For"), "x-real-ip", r.Header.Get("X-Real-Ip")) diff --git a/lib/policy/config/config.go b/lib/policy/config/config.go index b23af70..e8f5161 100644 --- a/lib/policy/config/config.go +++ b/lib/policy/config/config.go @@ -25,6 +25,7 @@ const ( RuleAllow Rule = "ALLOW" RuleDeny Rule = "DENY" RuleChallenge Rule = "CHALLENGE" + RuleBenchmark Rule = "DEBUG_BENCHMARK" ) type Algorithm string @@ -80,7 +81,7 @@ func (b BotConfig) Valid() error { } switch b.Action { - case RuleAllow, RuleChallenge, RuleDeny: + case RuleAllow, RuleBenchmark, RuleChallenge, RuleDeny: // okay default: errs = append(errs, fmt.Errorf("%w: %q", ErrUnknownAction, b.Action)) diff --git a/web/build.sh b/web/build.sh index 70492d7..a513c59 100755 --- a/web/build.sh +++ b/web/build.sh @@ -7,4 +7,6 @@ cd "$(dirname "$0")" esbuild js/main.mjs --sourcemap --bundle --minify --outfile=static/js/main.mjs gzip -f -k static/js/main.mjs zstd -f -k --ultra -22 static/js/main.mjs -brotli -fZk static/js/main.mjs \ No newline at end of file +brotli -fZk static/js/main.mjs + +esbuild js/bench.mjs --sourcemap --bundle --minify --outfile=static/js/bench.mjs \ No newline at end of file diff --git a/web/index.go b/web/index.go index 7057cc8..6ef84b5 100644 --- a/web/index.go +++ b/web/index.go @@ -13,3 +13,7 @@ func Index() templ.Component { func ErrorPage(msg string) templ.Component { return errorPage(msg) } + +func Bench() templ.Component { + return bench() +} diff --git a/web/index.templ b/web/index.templ index 1899ae3..b43e82c 100644 --- a/web/index.templ +++ b/web/index.templ @@ -121,3 +121,54 @@ templ errorPage(message string) {

Go home

} + +templ bench() { +
+ + + + + + + + + + + + + + + +
TimeIters
+
+ Loading...

+
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + var _ = templruntime.GeneratedTemplate diff --git a/web/js/bench.mjs b/web/js/bench.mjs new file mode 100644 index 0000000..c8c69bd --- /dev/null +++ b/web/js/bench.mjs @@ -0,0 +1,152 @@ +import processFast from "./proof-of-work.mjs"; +import processSlow from "./proof-of-work-slow.mjs"; + +const defaultDifficulty = 4; +const algorithms = { + fast: processFast, + slow: processSlow, +}; + +const status = document.getElementById("status"); +const difficultyInput = document.getElementById("difficulty-input"); +const algorithmSelect = document.getElementById("algorithm-select"); +const compareSelect = document.getElementById("compare-select"); +const header = document.getElementById("table-header"); +const headerCompare = document.getElementById("table-header-compare"); +const results = document.getElementById("results"); + +const setupControls = () => { + difficultyInput.value = defaultDifficulty; + for (const alg of Object.keys(algorithms)) { + const option1 = document.createElement("option"); + algorithmSelect.append(option1); + const option2 = document.createElement("option"); + compareSelect.append(option2); + option1.value = option1.innerText = option2.value = option2.innerText = alg; + } +}; + +const benchmarkTrial = async (stats, difficulty, algorithm, signal) => { + if (!(difficulty >= 1)) { + throw new Error(`Invalid difficulty: ${difficulty}`); + } + const process = algorithms[algorithm]; + if (process == null) { + throw new Error(`Unknown algorithm: ${algorithm}`); + } + + const rawChallenge = new Uint8Array(32); + crypto.getRandomValues(rawChallenge); + const challenge = Array.from(rawChallenge) + .map((c) => c.toString(16).padStart(2, "0")) + .join(""); + + const t0 = performance.now(); + const { hash, nonce } = await process(challenge, Number(difficulty), signal); + const t1 = performance.now(); + console.log({ hash, nonce }); + + stats.time += t1 - t0; + stats.iters += nonce; + + return { time: t1 - t0, nonce }; +}; + +const stats = { time: 0, iters: 0 }; +const comparison = { time: 0, iters: 0 }; +const updateStatus = () => { + const mainRate = stats.iters / stats.time; + const compareRate = comparison.iters / comparison.time; + if (Number.isFinite(mainRate)) { + status.innerText = `Average hashrate: ${mainRate.toFixed(3)}kH/s`; + if (Number.isFinite(compareRate)) { + const change = ((mainRate - compareRate) / mainRate) * 100; + status.innerText += ` vs ${compareRate.toFixed(3)}kH/s (${change.toFixed(2)}% change)`; + } + } else { + status.innerText = "Benchmarking..."; + } +}; + +const tableCell = (text) => { + const td = document.createElement("td"); + td.innerText = text; + td.style.padding = "0 0.25rem"; + return td; +}; + +const benchmarkLoop = async (controller) => { + const difficulty = difficultyInput.value; + const algorithm = algorithmSelect.value; + const compareAlgorithm = compareSelect.value; + updateStatus(); + + try { + const { time, nonce } = await benchmarkTrial( + stats, + difficulty, + algorithm, + controller.signal, + ); + + const tr = document.createElement("tr"); + tr.style.display = "contents"; + tr.append(tableCell(`${time}ms`), tableCell(nonce)); + + // auto-scroll to new rows + const atBottom = + results.scrollHeight - results.clientHeight <= results.scrollTop; + results.append(tr); + if (atBottom) { + results.scrollTop = results.scrollHeight - results.clientHeight; + } + updateStatus(); + + if (compareAlgorithm !== "NONE") { + const { time, nonce } = await benchmarkTrial( + comparison, + difficulty, + compareAlgorithm, + controller.signal, + ); + tr.append(tableCell(`${time}ms`), tableCell(nonce)); + } + } catch (e) { + if (e !== false) { + status.innerText = e; + } + return; + } + + benchmarkLoop(controller); +}; + +let controller = null; +const reset = () => { + stats.time = stats.iters = 0; + comparison.time = comparison.iters = 0; + results.innerHTML = status.innerText = ""; + + const table = results.parentElement; + if (compareSelect.value !== "NONE") { + table.style.gridTemplateColumns = "repeat(4,auto)"; + header.style.display = "none"; + headerCompare.style.display = "contents"; + } else { + table.style.gridTemplateColumns = "repeat(2,auto)"; + header.style.display = "contents"; + headerCompare.style.display = "none"; + } + + if (controller != null) { + controller.abort(); + } + controller = new AbortController(); + benchmarkLoop(controller); +}; + +setupControls(); +difficultyInput.addEventListener("change", reset); +algorithmSelect.addEventListener("change", reset); +compareSelect.addEventListener("change", reset); +reset(); \ No newline at end of file diff --git a/web/js/main.mjs b/web/js/main.mjs index 01f21f0..3203e4a 100644 --- a/web/js/main.mjs +++ b/web/js/main.mjs @@ -127,6 +127,7 @@ const dependencies = [ const { hash, nonce } = await process( challenge, rules.difficulty, + null, (iters) => { const delta = Date.now() - t0; // only update the speed every second so it's less visually distracting diff --git a/web/js/proof-of-work-slow.mjs b/web/js/proof-of-work-slow.mjs index 6522c0b..0bdc146 100644 --- a/web/js/proof-of-work-slow.mjs +++ b/web/js/proof-of-work-slow.mjs @@ -3,6 +3,7 @@ export default function process( data, difficulty = 5, + signal = null, progressCallback = null, _threads = 1, ) { @@ -13,19 +14,33 @@ export default function process( ], { type: 'application/javascript' })); let worker = new Worker(webWorkerURL); + const terminate = () => { + worker.terminate(); + if (signal != null) { + // clean up listener to avoid memory leak + signal.removeEventListener("abort", terminate); + if (signal.aborted) { + console.log("PoW aborted"); + reject(false); + } + } + }; + if (signal != null) { + signal.addEventListener("abort", terminate, { once: true }); + } worker.onmessage = (event) => { if (typeof event.data === "number") { progressCallback?.(event.data); } else { - worker.terminate(); + terminate(); resolve(event.data); } }; worker.onerror = (event) => { - worker.terminate(); - reject(); + terminate(); + reject(event); }; worker.postMessage({ diff --git a/web/js/proof-of-work.mjs b/web/js/proof-of-work.mjs index 60d8d61..a04f5ca 100644 --- a/web/js/proof-of-work.mjs +++ b/web/js/proof-of-work.mjs @@ -1,6 +1,7 @@ export default function process( data, difficulty = 5, + signal = null, progressCallback = null, threads = (navigator.hardwareConcurrency || 1), ) { @@ -11,6 +12,20 @@ export default function process( ], { type: 'application/javascript' })); const workers = []; + const terminate = () => { + workers.forEach((w) => w.terminate()); + if (signal != null) { + // clean up listener to avoid memory leak + signal.removeEventListener("abort", terminate); + if (signal.aborted) { + console.log("PoW aborted"); + reject(false); + } + } + }; + if (signal != null) { + signal.addEventListener("abort", terminate, { once: true }); + } for (let i = 0; i < threads; i++) { let worker = new Worker(webWorkerURL); @@ -19,14 +34,14 @@ export default function process( if (typeof event.data === "number") { progressCallback?.(event.data); } else { - workers.forEach(worker => worker.terminate()); + terminate(); resolve(event.data); } }; worker.onerror = (event) => { - worker.terminate(); - reject(); + terminate(); + reject(event); }; worker.postMessage({