diff --git a/cmd/yggdrasil/dashboard.go b/cmd/yggdrasil/dashboard.go index f941b80..2e8a852 100644 --- a/cmd/yggdrasil/dashboard.go +++ b/cmd/yggdrasil/dashboard.go @@ -18,7 +18,7 @@ import ( "github.com/yggdrasil-network/yggdrasil-go/src/address" ) -//go:embed dashboard.html +//go:embed dashboard.html dashboard_public.html var dashboardHTML embed.FS type dashboardServer struct { @@ -91,6 +91,7 @@ func startDashboard(n *node, logger *log.Logger, listenAddr, username, password mux := http.NewServeMux() mux.HandleFunc("/", d.handleIndex) mux.HandleFunc("/api/status", d.handleStatus) + mux.HandleFunc("/api/status/admin", d.handleStatusAdmin) mux.HandleFunc("/api/peer/traffic", d.handlePeerTraffic) mux.HandleFunc("/api/peer/ban", d.handlePeerBan) @@ -144,7 +145,11 @@ func (d *dashboardServer) withAuth(next http.Handler) http.Handler { } func (d *dashboardServer) handleIndex(w http.ResponseWriter, _ *http.Request) { - bs, err := dashboardHTML.ReadFile("dashboard.html") + page := "dashboard.html" + if d.readOnly { + page = "dashboard_public.html" + } + bs, err := dashboardHTML.ReadFile(page) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -154,6 +159,22 @@ func (d *dashboardServer) handleIndex(w http.ResponseWriter, _ *http.Request) { } func (d *dashboardServer) handleStatus(w http.ResponseWriter, _ *http.Request) { + status := d.buildStatus(!d.readOnly) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(status) +} + +func (d *dashboardServer) handleStatusAdmin(w http.ResponseWriter, _ *http.Request) { + if d.readOnly { + http.Error(w, "admin status is unavailable on the public dashboard", http.StatusForbidden) + return + } + status := d.buildStatus(true) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(status) +} + +func (d *dashboardServer) buildStatus(showAdditional bool) dashboardStatus { status := dashboardStatus{Now: time.Now()} self := d.node.core.GetSelf() subnet := d.node.core.Subnet() @@ -184,23 +205,24 @@ func (d *dashboardServer) handleStatus(w http.ResponseWriter, _ *http.Request) { }) } 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)}) + if showAdditional { + 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) + return status } func (d *dashboardServer) handlePeerTraffic(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/yggdrasil/dashboard.html b/cmd/yggdrasil/dashboard.html index 3a70863..c744c6b 100644 --- a/cmd/yggdrasil/dashboard.html +++ b/cmd/yggdrasil/dashboard.html @@ -55,6 +55,7 @@ svg.port-icon { width: 24px; height: 24px; } svg.signal-icon { width: 16px; height: 16px; } .signal-bar { fill: #ddd; transition: fill 0.2s; } .signal-bar.active { fill: var(--network-green); } +.signal-icon.offline .signal-bar.active { fill: #9ca3af; } .graph-container { width: 100%; height: 220px; background: #fafafa; border: 1px solid var(--border-color); border-radius: 4px; overflow: hidden; } canvas#trafficGraph { width: 100%; height: 100%; display: block; } @@ -360,6 +361,24 @@ canvas#trafficGraph { width: 100%; height: 100%; display: block; } body { padding: 12px; } .grid { grid-template-columns: 1fr; } } +.project-board { + margin-top: 16px; + display: flex; + align-items: center; + gap: 10px; + background: #fff; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 12px; + font-size: 13px; + color: var(--text-secondary); +} +.project-board a { + color: var(--text-main); + text-decoration: none; + font-weight: 600; +} +.project-board a:hover { text-decoration: underline; } @@ -413,6 +432,14 @@ canvas#trafficGraph { width: 100%; height: 100%; display: block; } +
+ + + Kaya source: + https://git.protogen.engineering/racks/kaya-go + +
+
@@ -701,7 +728,10 @@ function buildPeersTable(peers) { // Calculate signal strength (0-4, where 4 is best/lowest cost) const cost = safeNum(p.cost); let signalStrength = 0; - if (maxCost !== minCost) { + const isDownWithZeroCost = !p.up && cost === 0; + if (isDownWithZeroCost) { + signalStrength = 4; + } else if (maxCost !== minCost) { const normalized = (maxCost - cost) / (maxCost - minCost); // 0 (worst/highest cost) to 1 (best/lowest cost) signalStrength = Math.round(normalized * 4); // 0-4 } else { @@ -709,11 +739,12 @@ function buildPeersTable(peers) { } if (signalStrength < 0) signalStrength = 0; if (signalStrength > 4) signalStrength = 4; + const signalStateClass = isDownWithZeroCost ? ' offline' : ''; // Generate cell phone signal icon SVG based on signal strength (0-4) const signalIconHtml = '
' + - ' + + +Kaya Dashboard + + + +
+

Kaya

+
Loading…
+
+ +
+
+

Self Info

+
\{\}
+
+ + +
+

Flows (Sessions)

+
+
+ +
+

Peers

+
+
+ + +
+

Paths

+
+
+ + +
+

Tree

+
+
+ +
+

Total Bandwidth (Real-time)

+
+
+
+
⬇ Download: 0.00 Mbit/s
+
+
+
+
⬆ Upload: 0.00 Mbit/s
+
+
+
+
+
+ +
+ + + Kaya source: + https://git.protogen.engineering/racks/kaya-go + +
+ + +
+ + +
+ + +
+ + + + diff --git a/src/core/link_quic.go b/src/core/link_quic.go index 40d2021..8483d7e 100644 --- a/src/core/link_quic.go +++ b/src/core/link_quic.go @@ -7,6 +7,7 @@ import ( "net" "net/url" "strconv" + "strings" "time" "github.com/Arceliar/phony" @@ -66,13 +67,19 @@ func (l *linkQUIC) dial(ctx context.Context, url *url.URL, info linkInfo, option tlsconfig.ServerName = hostname tlsconfig.MinVersion = tls.VersionTLS12 tlsconfig.MaxVersion = tls.VersionTLS13 - hostport := net.JoinHostPort(ip.String(), strconv.Itoa(port)) - qc, err := quic.DialAddr(ctx, hostport, tlsconfig, l.quicconfig) + remoteAddr := &net.UDPAddr{IP: ip, Port: port} + conn, err := l.boundPacketConn(ctx, info.sintf, ip, 0) if err != nil { return nil, err } + qc, err := quic.Dial(ctx, conn, remoteAddr, tlsconfig, l.quicconfig) + if err != nil { + _ = conn.Close() + return nil, err + } qs, err := qc.OpenStreamSync(ctx) if err != nil { + _ = qc.CloseWithError(1, "failed to open stream") return nil, err } return &linkQUICStream{ @@ -82,11 +89,42 @@ func (l *linkQUIC) dial(ctx context.Context, url *url.URL, info linkInfo, option }) } -func (l *linkQUIC) listen(ctx context.Context, url *url.URL, _ string) (net.Listener, error) { - ql, err := quic.ListenAddr(url.Host, l.tlsconfig, l.quicconfig) +func (l *linkQUIC) listen(ctx context.Context, url *url.URL, sintf string) (net.Listener, error) { + host, p, err := net.SplitHostPort(url.Host) if err != nil { return nil, err } + port, err := strconv.Atoi(p) + if err != nil { + return nil, err + } + ip := net.ParseIP(host) + if sintf == "" || ip == nil || ip.IsUnspecified() { + hostport := url.Host + if sintf != "" && strings.Contains(host, ":") { + hostport = fmt.Sprintf("[%s%%%s]:%d", host, sintf, port) + } + ql, err := quic.ListenAddr(hostport, l.tlsconfig, l.quicconfig) + if err != nil { + return nil, err + } + return l.wrapQUICListener(ctx, ql), nil + } + packetConn, err := l.boundPacketConn(ctx, sintf, ip, port) + if err != nil { + return nil, err + } + transport := quic.Transport{Conn: packetConn} + ql, err := transport.Listen(l.tlsconfig, l.quicconfig) + if err != nil { + _ = packetConn.Close() + return nil, err + } + lql := l.wrapQUICListener(ctx, ql) + return lql, nil +} + +func (l *linkQUIC) wrapQUICListener(ctx context.Context, ql *quic.Listener) *linkQUICListener { ch := make(chan *linkQUICStream) lql := &linkQUICListener{ Listener: ql, @@ -114,5 +152,41 @@ func (l *linkQUIC) listen(ctx context.Context, url *url.URL, _ string) (net.List } } }() - return lql, nil + return lql +} + +func (l *linkQUIC) boundPacketConn(ctx context.Context, sintf string, remoteIP net.IP, localPort int) (net.PacketConn, error) { + if sintf == "" { + return net.ListenPacket("udp", ":0") + } + info, err := l.tcp.getInterfaceInfo(sintf) + if err != nil { + return nil, err + } + if !info.up { + return nil, fmt.Errorf("interface %q is not up", sintf) + } + var src net.IP + wantV6 := remoteIP != nil && remoteIP.To4() == nil + for _, addr := range info.addrs { + if wantV6 && addr.To4() == nil { + src = addr + break + } + if !wantV6 && addr.To4() != nil { + src = addr + break + } + } + if src == nil { + return nil, fmt.Errorf("no suitable source address found on interface %q", sintf) + } + lc := net.ListenConfig{} + lc.Control = l.tcp.getControl(sintf) + network := "udp4" + if src.To4() == nil { + network = "udp6" + } + addr := (&net.UDPAddr{IP: src, Port: localPort, Zone: sintf}).String() + return lc.ListenPacket(ctx, network, addr) }