Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
761 changes: 573 additions & 188 deletions .github/img/ratatoskr_logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion cmd/embedded/http/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ go 1.25.5
require (
github.com/gologme/log v1.3.0
github.com/klauspost/compress v1.17.4
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/voluminor/ratatoskr v0.0.0
github.com/yggdrasil-network/yggdrasil-go v0.5.13
golang.org/x/net v0.52.0
Expand Down
97 changes: 97 additions & 0 deletions cmd/embedded/http/handler_ninfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package main

import (
"context"
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"net/http"
"time"

htmlsigils "github.com/voluminor/ratatoskr/mod/html/sigils"
"github.com/voluminor/ratatoskr/mod/ninfo"
)

// // // // // // // // // //

type ninfoResponseJSON struct {
Target string `json:"target"`
RTT float64 `json:"rtt_ms"`
Version string `json:"version,omitempty"`
Software *ninfo.SoftwareObj `json:"software,omitempty"`
SigilsHTML map[string]string `json:"sigils_html,omitempty"`
ExtraHTML string `json:"extra_html,omitempty"`
CSS string `json:"css"`
Error string `json:"error,omitempty"`
}

// //

func newNinfoHandler(ni *ninfo.Obj) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
keyHex := r.URL.Query().Get("key")
if keyHex == "" {
writeNinfoError(w, "missing 'key' query parameter")
return
}

keyBytes, err := hex.DecodeString(keyHex)
if err != nil || len(keyBytes) != ed25519.PublicKeySize {
writeNinfoError(w, "invalid public key: must be 64-char hex string (32 bytes)")
return
}

ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()

result, err := ni.Ask(ctx, ed25519.PublicKey(keyBytes))
if err != nil {
writeNinfoError(w, err.Error())
return
}

resp := ninfoResponseJSON{
Target: keyHex,
RTT: float64(result.RTT.Microseconds()) / 1000.0,
CSS: string(htmlsigils.CSS),
}

if result.Software != nil {
resp.Software = result.Software
}

if result.Node != nil {
resp.Version = result.Node.Version

rendered, err := htmlsigils.Render(result.Node)
if err == nil {
if len(rendered.Sigils) > 0 {
resp.SigilsHTML = make(map[string]string, len(rendered.Sigils))
for name, buf := range rendered.Sigils {
if buf != nil {
resp.SigilsHTML[name] = string(buf)
}
}
}
if rendered.Extra != nil {
resp.ExtraHTML = string(rendered.Extra)
}
}
}

data, _ := json.Marshal(resp)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(data)
})
}

// //

func writeNinfoError(w http.ResponseWriter, msg string) {
resp := ninfoResponseJSON{Error: msg, CSS: string(htmlsigils.CSS)}
data, _ := json.Marshal(resp)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write(data)
}
35 changes: 22 additions & 13 deletions cmd/embedded/http/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import (
"time"

golog "github.com/gologme/log"
qrcode "github.com/skip2/go-qrcode"
yggconfig "github.com/yggdrasil-network/yggdrasil-go/src/config"

"github.com/voluminor/ratatoskr"
"github.com/voluminor/ratatoskr/mod/core"
htmlimg "github.com/voluminor/ratatoskr/mod/html/img"
"github.com/voluminor/ratatoskr/mod/ninfo"
"github.com/voluminor/ratatoskr/mod/peermgr"
"github.com/voluminor/ratatoskr/mod/probe"
)
Expand Down Expand Up @@ -87,22 +88,29 @@ func main() {
}
defer tr.Close()

ni, err := ninfo.New(coreNode.UnsafeCore(), logger)
if err != nil {
fmt.Println("Error: ninfo:", err)
os.Exit(1)
}
defer ni.Close()

info := newInfoHandler(node, tr, cfg, logger)
traceHandler := newTraceHandler(tr)
treeHandler := newTreeHandler(tr)
treeWSHandler := newTreeWSHandler(tr)
ninfoHandler := newNinfoHandler(ni)

yggAddr := node.Address().String()
qrURL := fmt.Sprintf("http://[%s]:%d/", yggAddr, cfg.YggPorts[0])
qrSVG, err := htmlimg.QRCode(node.PublicKey())
if err != nil {
fmt.Println("Error: generate QR:", err)
os.Exit(1)
}
qrHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
png, err := qrcode.Encode(qrURL, qrcode.Medium, 256)
if err != nil {
http.Error(w, "qr error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(png)
_, _ = w.Write(qrSVG)
})

// Plain HTTP servers
Expand All @@ -113,7 +121,7 @@ func main() {
os.Exit(1)
}
go (&http.Server{
Handler: buildMux(*wwwPath, info, false, qrHandler, traceHandler, treeHandler, treeWSHandler),
Handler: buildMux(*wwwPath, info, false, qrHandler, traceHandler, treeHandler, treeWSHandler, ninfoHandler),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}).Serve(l)
Expand All @@ -129,7 +137,7 @@ func main() {
os.Exit(1)
}
go (&http.Server{
Handler: buildMux(*wwwPath, info, true, qrHandler, traceHandler, treeHandler, treeWSHandler),
Handler: buildMux(*wwwPath, info, true, qrHandler, traceHandler, treeHandler, treeWSHandler, ninfoHandler),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}).Serve(l)
Expand All @@ -141,13 +149,14 @@ func main() {

// //

func buildMux(wwwPath string, info *InfoHandlerObj, isYgg bool, qr, trace, tree, treeWS http.Handler) *http.ServeMux {
func buildMux(wwwPath string, info *InfoHandlerObj, isYgg bool, qr, trace, tree, treeWS, ninfoH http.Handler) *http.ServeMux {
mux := http.NewServeMux()
mux.Handle("/yggdrasil-server.json", info.Handler(isYgg))
mux.Handle("/ygg-qr.png", qr)
mux.Handle("/ygg-qr.svg", qr)
mux.Handle("/probe.json", trace)
mux.Handle("/tree.json", tree)
mux.Handle("/tree-ws", treeWS)
mux.Handle("/ninfo.json", ninfoH)
if isYgg {
mux.Handle("/", newYggFileHandler(wwwPath))
} else {
Expand Down
117 changes: 116 additions & 1 deletion cmd/embedded/http/www/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,10 @@ function onTreeMessage(ev) {
}

document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeTreeModal();
if (e.key === 'Escape') {
closeTreeModal();
closeNinfoModal();
}
});

// //
Expand Down Expand Up @@ -533,6 +536,118 @@ document.getElementById('trace-key').addEventListener('keydown', function (e) {
if (e.key === 'Enter') doTrace();
});

// // // // // // // // // //

// NodeInfo modal

async function doNinfo() {
const keyInput = document.getElementById('trace-key');
const btn = document.getElementById('ninfo-btn');
const errorEl = document.getElementById('trace-error');

const key = keyInput.value.trim();
if (!key || key.length !== 64 || !/^[0-9a-fA-F]+$/.test(key)) {
errorEl.textContent = 'Enter a valid 64-char hex public key';
errorEl.classList.remove('hidden');
return;
}

btn.disabled = true;
btn.textContent = '\u2026';
errorEl.classList.add('hidden');

try {
const resp = await fetch('/ninfo.json?key=' + key);
const data = await resp.json();

if (data.error) {
errorEl.textContent = data.error;
errorEl.classList.remove('hidden');
return;
}

renderNinfoModal(data);
} catch (e) {
errorEl.textContent = 'Request failed: ' + e.message;
errorEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = 'NodeInfo';
}
}

function renderNinfoModal(data) {
const body = document.getElementById('ninfo-modal-body');
body.innerHTML = '';

// Inject scoped CSS from server
if (data.css) {
const style = document.createElement('style');
style.textContent = data.css;
body.appendChild(style);
}

// RTT badge
const rtt = document.createElement('div');
rtt.className = 'ninfo-rtt';
rtt.textContent = data.rtt_ms.toFixed(2) + ' ms';
body.appendChild(rtt);

// Version header (ratatoskr node)
if (data.version) {
const ver = document.createElement('div');
ver.className = 'ninfo-version';
ver.textContent = 'ratatoskr ' + data.version;
body.appendChild(ver);
}

// Software metadata
if (data.software) {
const sw = data.software;
const parts = [sw.Name, sw.Version, sw.Platform, sw.Arch].filter(Boolean);
if (parts.length > 0) {
const el = document.createElement('div');
el.className = 'ninfo-software';
el.textContent = parts.join(' / ');
body.appendChild(el);
}
}

// Sigils (rendered HTML from server)
if (data.sigils_html) {
const section = document.createElement('div');
section.className = 'ninfo-sigils';
for (const [name, html] of Object.entries(data.sigils_html)) {
const block = document.createElement('div');
block.innerHTML = html;
section.appendChild(block);
}
body.appendChild(section);
}

// Extra fields (rendered HTML from server)
if (data.extra_html) {
const section = document.createElement('div');
section.className = 'ninfo-extra';
const header = document.createElement('div');
header.className = 'ninfo-section-header';
header.textContent = 'Extra';
section.appendChild(header);

const content = document.createElement('div');
content.innerHTML = data.extra_html;
section.appendChild(content);
body.appendChild(section);
}

document.getElementById('ninfo-modal').classList.remove('hidden');
}

function closeNinfoModal(e) {
if (e && e.target !== e.currentTarget) return;
document.getElementById('ninfo-modal').classList.add('hidden');
}

// //

function tickChart() {
Expand Down
14 changes: 13 additions & 1 deletion cmd/embedded/http/www/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ <h2>Node</h2>
</div>
<div class="card qr-card">
<h2>Share via Yggdrasil</h2>
<img id="qr-img" src="/ygg-qr.png" alt="Yggdrasil QR code">
<img id="qr-img" src="/ygg-qr.svg" alt="Yggdrasil QR code">
<p class="qr-hint">Scan to open in an Yggdrasil-connected browser</p>
</div>
</div>
Expand Down Expand Up @@ -74,6 +74,7 @@ <h2>Traceroute</h2>
<input id="trace-key" type="text" placeholder="Public key (hex, 64 chars)"
maxlength="64" spellcheck="false" autocomplete="off">
<button id="trace-btn" onclick="doTrace()">Trace</button>
<button id="ninfo-btn" onclick="doNinfo()">NodeInfo</button>
</div>
<div id="trace-error" class="trace-error hidden"></div>
<div id="trace-result" class="hidden">
Expand Down Expand Up @@ -116,6 +117,17 @@ <h2>Spanning Tree</h2>
</div>
</div>

<div id="ninfo-modal" class="modal-overlay hidden" onclick="closeNinfoModal(event)">
<div class="modal-content" onclick="event.stopPropagation()">
<div class="modal-header">
<h2>NodeInfo</h2>
<button class="modal-close" onclick="closeNinfoModal()">&times;</button>
</div>
<div id="ninfo-modal-body" class="ninfo-modal-body">
</div>
</div>
</div>

<script src="app.js"></script>
</body>
</html>
Loading
Loading