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() }