This commit is contained in:
Racks 2026-03-01 17:01:47 +01:00
commit 1d167420c3
89 changed files with 10707 additions and 0 deletions

329
cmd/yggdrasil/dashboard.go Normal file
View file

@ -0,0 +1,329 @@
package main
import (
"context"
"crypto/subtle"
"embed"
"encoding/hex"
"encoding/json"
"net"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/gologme/log"
"github.com/yggdrasil-network/yggdrasil-go/src/address"
)
//go:embed dashboard.html
var dashboardHTML embed.FS
type dashboardServer struct {
node *node
logger *log.Logger
username string
password string
readOnly bool
banned map[string]struct{}
mu sync.RWMutex
http *http.Server
}
type dashboardStatus struct {
Self any `json:"self"`
Peers []dashboardPeer `json:"peers"`
Sessions []dashboardFlow `json:"sessions"`
Paths []dashboardPath `json:"paths"`
Tree []dashboardTree `json:"tree"`
Banned []string `json:"banned"`
Now time.Time `json:"now"`
}
type dashboardPeer struct {
URI string `json:"uri"`
Remote string `json:"remote"`
Up bool `json:"up"`
Inbound bool `json:"inbound"`
IP string `json:"ip"`
Uptime string `json:"uptime"`
RTT string `json:"rtt"`
RX uint64 `json:"rx"`
TX uint64 `json:"tx"`
RXRate uint64 `json:"rx_rate"`
TXRate uint64 `json:"tx_rate"`
Priority uint8 `json:"priority"`
Cost uint64 `json:"cost"`
Banned bool `json:"banned"`
}
type dashboardFlow struct {
IP string `json:"ip"`
RX uint64 `json:"rx"`
TX uint64 `json:"tx"`
Uptime string `json:"uptime"`
}
type dashboardPath struct {
IP string `json:"ip"`
Path []uint64 `json:"path"`
}
type dashboardTree struct {
IP string `json:"ip"`
Parent string `json:"parent"`
}
func startDashboard(n *node, logger *log.Logger, listenAddr, username, password string, readOnly bool) (*dashboardServer, error) {
if strings.TrimSpace(listenAddr) == "" {
return nil, nil
}
d := &dashboardServer{
node: n,
logger: logger,
username: username,
password: password,
readOnly: readOnly,
banned: make(map[string]struct{}),
}
mux := http.NewServeMux()
mux.HandleFunc("/", d.handleIndex)
mux.HandleFunc("/api/status", d.handleStatus)
mux.HandleFunc("/api/peer/traffic", d.handlePeerTraffic)
mux.HandleFunc("/api/peer/ban", d.handlePeerBan)
d.http = &http.Server{
Addr: listenAddr,
Handler: d.withAuth(mux),
ReadHeaderTimeout: 5 * time.Second,
}
ln, err := net.Listen("tcp", listenAddr)
if err != nil {
return nil, err
}
go func() {
if err := d.http.Serve(ln); err != nil && err != http.ErrServerClosed {
d.logger.Errorf("dashboard failed: %v", err)
}
}()
if readOnly {
logger.Infof("Public dashboard listening on http://%s", ln.Addr().String())
return d, nil
}
logger.Infof("Dashboard listening on http://%s", ln.Addr().String())
if username == "" || password == "" {
logger.Warnln("Dashboard authentication disabled; set both -dashboard-user and -dashboard-password to secure it")
}
return d, nil
}
func (d *dashboardServer) stop() {
if d == nil || d.http == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = d.http.Shutdown(ctx)
}
func (d *dashboardServer) withAuth(next http.Handler) http.Handler {
if d.username == "" || d.password == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || subtle.ConstantTimeCompare([]byte(u), []byte(d.username)) != 1 || subtle.ConstantTimeCompare([]byte(p), []byte(d.password)) != 1 {
w.Header().Set("WWW-Authenticate", `Basic realm="kaya"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func (d *dashboardServer) handleIndex(w http.ResponseWriter, _ *http.Request) {
bs, err := dashboardHTML.ReadFile("dashboard.html")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(bs)
}
func (d *dashboardServer) handleStatus(w http.ResponseWriter, _ *http.Request) {
status := dashboardStatus{Now: time.Now()}
self := d.node.core.GetSelf()
subnet := d.node.core.Subnet()
status.Self = map[string]any{
"public_key": hex.EncodeToString(self.Key),
"ip_address": d.node.core.Address().String(),
"subnet": (&subnet).String(),
"routing_entries": self.RoutingEntries,
}
for _, p := range d.node.core.GetPeers() {
uriKey := canonicalPeerKey(p.URI, "")
status.Peers = append(status.Peers, dashboardPeer{
URI: p.URI,
Remote: peerHostFromURI(p.URI),
Up: p.Up,
Inbound: p.Inbound,
IP: keyToIP(p.Key),
Uptime: p.Uptime.String(),
RTT: p.Latency.String(),
RX: p.RXBytes,
TX: p.TXBytes,
RXRate: p.RXRate,
TXRate: p.TXRate,
Priority: p.Priority,
Cost: p.Cost,
Banned: d.isBanned(uriKey),
})
}
sort.Slice(status.Peers, func(i, j int) bool { return status.Peers[i].URI < status.Peers[j].URI })
for _, s := range d.node.core.GetSessions() {
status.Sessions = append(status.Sessions, dashboardFlow{
IP: keyToIP(s.Key),
RX: s.RXBytes,
TX: s.TXBytes,
Uptime: s.Uptime.String(),
})
}
for _, p := range d.node.core.GetPaths() {
status.Paths = append(status.Paths, dashboardPath{IP: keyToIP(p.Key), Path: p.Path})
}
for _, t := range d.node.core.GetTree() {
status.Tree = append(status.Tree, dashboardTree{IP: keyToIP(t.Key), Parent: keyToIP(t.Parent)})
}
status.Banned = d.bannedList()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(status)
}
func (d *dashboardServer) handlePeerTraffic(w http.ResponseWriter, r *http.Request) {
if d.readOnly {
http.Error(w, "public dashboard is read-only", http.StatusForbidden)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
URI string `json:"uri"`
Interface string `json:"interface"`
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
uri, err := url.Parse(req.URI)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
key := canonicalPeerKey(req.URI, req.Interface)
if req.Enabled {
if d.isBanned(key) {
http.Error(w, "peer is banned", http.StatusForbidden)
return
}
err = d.node.core.AddPeer(uri, req.Interface)
} else {
err = d.node.core.RemovePeer(uri, req.Interface)
}
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (d *dashboardServer) handlePeerBan(w http.ResponseWriter, r *http.Request) {
if d.readOnly {
http.Error(w, "public dashboard is read-only", http.StatusForbidden)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
URI string `json:"uri"`
Interface string `json:"interface"`
Banned bool `json:"banned"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
uri, err := url.Parse(req.URI)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
key := canonicalPeerKey(req.URI, req.Interface)
if req.Banned {
d.mu.Lock()
d.banned[key] = struct{}{}
d.mu.Unlock()
_ = d.node.core.RemovePeer(uri, req.Interface)
} else {
d.mu.Lock()
delete(d.banned, key)
d.mu.Unlock()
}
w.WriteHeader(http.StatusNoContent)
}
func (d *dashboardServer) isBanned(k string) bool {
d.mu.RLock()
_, ok := d.banned[k]
d.mu.RUnlock()
return ok
}
func (d *dashboardServer) bannedList() []string {
d.mu.RLock()
out := make([]string, 0, len(d.banned))
for k := range d.banned {
out = append(out, k)
}
d.mu.RUnlock()
sort.Strings(out)
return out
}
func canonicalPeerKey(uri, intf string) string { return uri + "|" + intf }
func peerHostFromURI(u string) string {
pu, err := url.Parse(u)
if err != nil {
return "-"
}
host := pu.Host
if h, _, err := net.SplitHostPort(host); err == nil {
return h
}
if host == "" {
return "-"
}
return host
}
func keyToIP(k []byte) string {
if len(k) == 0 {
return "-"
}
var key [32]byte
copy(key[:], k)
ip := net.IP(address.AddrForKey(key[:])[:])
if ip == nil {
return "-"
}
return ip.String()
}