329 lines
8.2 KiB
Go
329 lines
8.2 KiB
Go
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()
|
|
}
|