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

View file

@ -0,0 +1,9 @@
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris
package main
import "errors"
func chuser(user string) error {
return errors.New("setting uid/gid is not supported on this platform")
}

View file

@ -0,0 +1,62 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
package main
import (
"fmt"
"os/user"
"strconv"
"strings"
"golang.org/x/sys/unix"
)
func chuser(input string) error {
givenUser, givenGroup, _ := strings.Cut(input, ":")
if givenUser == "" {
return fmt.Errorf("user is empty")
}
if strings.Contains(input, ":") && givenGroup == "" {
return fmt.Errorf("group is empty")
}
var (
err error
usr *user.User
grp *user.Group
uid, gid int
)
if usr, err = user.LookupId(givenUser); err != nil {
if usr, err = user.Lookup(givenUser); err != nil {
return err
}
}
if uid, err = strconv.Atoi(usr.Uid); err != nil {
return err
}
if givenGroup != "" {
if grp, err = user.LookupGroupId(givenGroup); err != nil {
if grp, err = user.LookupGroup(givenGroup); err != nil {
return err
}
}
gid, _ = strconv.Atoi(grp.Gid)
} else {
gid, _ = strconv.Atoi(usr.Gid)
}
if err := unix.Setgroups([]int{gid}); err != nil {
return fmt.Errorf("setgroups: %d: %v", gid, err)
}
if err := unix.Setgid(gid); err != nil {
return fmt.Errorf("setgid: %d: %v", gid, err)
}
if err := unix.Setuid(uid); err != nil {
return fmt.Errorf("setuid: %d: %v", uid, err)
}
return nil
}

View file

@ -0,0 +1,79 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
package main
import (
"os/user"
"testing"
)
// Usernames must not contain a number sign.
func TestEmptyString(t *testing.T) {
if chuser("") == nil {
t.Fatal("the empty string is not a valid user")
}
}
// Either omit delimiter and group, or omit both.
func TestEmptyGroup(t *testing.T) {
if chuser("0:") == nil {
t.Fatal("the empty group is not allowed")
}
}
// Either user only or user and group.
func TestGroupOnly(t *testing.T) {
if chuser(":0") == nil {
t.Fatal("group only is not allowed")
}
}
// Usenames must not contain the number sign.
func TestInvalidUsername(t *testing.T) {
const username = "#user"
if chuser(username) == nil {
t.Fatalf("'%s' is not a valid username", username)
}
}
// User IDs must be non-negative.
func TestInvalidUserid(t *testing.T) {
if chuser("-1") == nil {
t.Fatal("User ID cannot be negative")
}
}
// Change to the current user by ID.
func TestCurrentUserid(t *testing.T) {
usr, err := user.Current()
if err != nil {
t.Fatal(err)
}
if usr.Uid != "0" {
t.Skip("setgroups(2): Only the superuser may set new groups.")
}
if err = chuser(usr.Uid); err != nil {
t.Fatal(err)
}
}
// Change to a common user by name.
func TestCommonUsername(t *testing.T) {
usr, err := user.Current()
if err != nil {
t.Fatal(err)
}
if usr.Uid != "0" {
t.Skip("setgroups(2): Only the superuser may set new groups.")
}
if err := chuser("nobody"); err != nil {
if _, ok := err.(user.UnknownUserError); ok {
t.Skip(err)
}
t.Fatal(err)
}
}

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

View file

@ -0,0 +1,251 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Kaya Dashboard</title>
<style>
:root {
--bg-color: #f4f4f4;
--card-bg: #ffffff;
--text-main: #1a1a1a;
--text-secondary: #666666;
--accent-orange: #ff6600;
--network-green: #00c853;
--border-color: #e0e0e0;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-mono: 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace;
}
* { box-sizing: border-box; }
body { font-family: var(--font-sans); background: var(--bg-color); color: var(--text-main); margin: 0; padding: 28px; line-height: 1.4; }
header { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding-bottom: 16px; margin-bottom: 22px; border-bottom: 6px solid var(--accent-orange); }
header h2 { margin: 0; font-size: 30px; font-weight: 800; letter-spacing: -1px; text-transform: uppercase; }
#stamp { font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); background: #fff; padding: 6px 12px; border: 1px solid var(--border-color); border-radius: 2px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 18px; }
.card { background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 4px; padding: 18px; box-shadow: 0 4px 6px rgba(0,0,0,0.02); min-width: 0; }
.card h3 { margin: 0 0 12px 0; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.2px; color: var(--accent-orange); border-bottom: 1px solid var(--border-color); padding-bottom: 8px; }
.table-wrap { overflow-x: auto; width: 100%; }
table { width: 100%; border-collapse: collapse; font-size: 12px; table-layout: fixed; }
th { font-weight: 600; color: var(--text-secondary); padding: 10px 6px; border-bottom: 2px solid var(--border-color); font-size: 11px; text-transform: uppercase; white-space: nowrap; }
td { padding: 8px 6px; border-bottom: 1px solid #eee; font-family: var(--font-mono); color: var(--text-main); vertical-align: middle; overflow: hidden; text-overflow: ellipsis; }
tr:last-child td { border-bottom: none; }
button { background: #fff; color: #d32f2f; border: 1px solid #d32f2f; border-radius: 3px; padding: 6px 10px; cursor: pointer; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; white-space: nowrap; }
button:hover { background: #d32f2f; color: #fff; }
pre { white-space: pre-wrap; margin: 0; font-family: var(--font-mono); font-size: 11px; background: #fafafa; padding: 12px; border: 1px solid var(--border-color); color: #555; max-height: 340px; overflow: auto; }
.net-green { color: var(--network-green); font-weight: bold; }
.full-width { grid-column: 1 / -1; }
.ip-cell { word-break: break-word; white-space: normal; }
.uri-cell { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.port-icon-wrapper { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; }
svg.port-icon { width: 24px; height: 24px; }
.port-body { fill: none; stroke: #ddd; stroke-width: 2; }
.port-led { fill: #ddd; transition: all 0.2s; }
.port-icon.connected .port-body { stroke: var(--accent-orange); }
.port-icon.connected .port-led { fill: var(--accent-orange); }
.port-icon.active .port-body { stroke: var(--network-green); }
.port-icon.active .port-led { fill: var(--network-green); animation: blink-traffic 1s steps(2) infinite; }
@keyframes blink-traffic { 0% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 1; } }
.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; }
.graph-stats { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 10px; font-family: var(--font-mono); font-size: 14px; flex-wrap: wrap; }
.stat-item { display: flex; align-items: center; gap: 8px; }
.stat-val { font-weight: bold; font-size: 16px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
@media (max-width: 900px) {
body { padding: 12px; }
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header>
<h2>Kaya</h2>
<div id="stamp"></div>
</header>
<div class="grid">
<div class="card">
<h3>Self Info</h3>
<pre id="self"></pre>
</div>
<div class="card">
<h3>Flows (Sessions)</h3>
<div class="table-wrap"><table id="flows"></table></div>
</div>
<div class="card full-width">
<h3>Peers Switch</h3>
<div class="table-wrap"><table id="peers"></table></div>
</div>
<div class="card">
<h3>Paths</h3>
<div class="table-wrap"><table id="paths"></table></div>
</div>
<div class="card">
<h3>Tree</h3>
<div class="table-wrap"><table id="tree"></table></div>
</div>
<div class="card full-width">
<h3>Total Bandwidth (Real-time)</h3>
<div class="graph-stats">
<div class="stat-item">
<div class="legend-dot" style="background: var(--network-green);"></div>
<div>⬇ Download: <span id="downSpeedEl" class="stat-val net-green">0.00 Mbit/s</span></div>
</div>
<div class="stat-item">
<div class="legend-dot" style="background: var(--accent-orange);"></div>
<div>⬆ Upload: <span id="upSpeedEl" class="stat-val" style="color: var(--accent-orange)">0.00 Mbit/s</span></div>
</div>
</div>
<div class="graph-container"><canvas id="trafficGraph"></canvas></div>
</div>
</div>
<script>
const peerStore = new Map();
const graphData = { maxPoints: 60, download: [], upload: [] };
async function call(path, payload){
const r = await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
if(!r.ok) alert(await r.text());
await reload();
}
function tHead(cols){return `<tr>${cols.map(c=>`<th>${c}</th>`).join('')}</tr>`}
function esc(v){return String(v??'').replace(/[&<>]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[m]))}
function peerAction(id, action){
const p = peerStore.get(id); if(!p) return;
if(action==='disconnect') return call('/api/peer/traffic',{uri:p.uri,enabled:false});
}
function bytesPerSecToMbit(bytesPerSec) {
const mbits = (Number(bytesPerSec) * 8) / 1000000;
return Number.isFinite(mbits) ? mbits : 0;
}
function fmtMbit(bytesPerSec) {
return bytesPerSecToMbit(bytesPerSec).toFixed(2) + ' Mbit/s';
}
function drawGraph() {
const canvas = document.getElementById('trafficGraph');
const ctx = canvas.getContext('2d');
const rect = canvas.parentNode.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = 1;
ctx.beginPath();
for(let i=0; i<5; i++) {
const y = (h/4) * i;
ctx.moveTo(0, y);
ctx.lineTo(w, y);
}
ctx.stroke();
if (graphData.download.length < 2) return;
const maxVal = Math.max(...graphData.download, ...graphData.upload) * 1.1 || 1;
const drawLine = (data, color) => {
ctx.strokeStyle = color.trim();
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.beginPath();
const step = w / (graphData.maxPoints - 1);
data.forEach((val, index) => {
const x = index * step;
const y = h - ((val / maxVal) * h);
if (index === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.lineTo(w, h);
ctx.lineTo(0, h);
ctx.fillStyle = color.trim() + '22';
ctx.fill();
};
drawLine(graphData.upload, getComputedStyle(document.documentElement).getPropertyValue('--accent-orange'));
drawLine(graphData.download, getComputedStyle(document.documentElement).getPropertyValue('--network-green'));
}
async function reload(){
const res = await fetch('/api/status');
if (!res.ok) {
throw new Error('Failed to load dashboard status: HTTP ' + res.status);
}
const s = await res.json();
document.getElementById('stamp').textContent = 'Updated ' + new Date(s.now).toLocaleString();
document.getElementById('self').textContent = JSON.stringify(s.self,null,2);
let totalRx = 0, totalTx = 0;
const peers = [tHead(['Port','URI','Remote','Up','IP','Uptime','RTT','RX','TX','⬇ RX Rate','⬆ TX Rate','Action'])];
peerStore.clear();
for(const p of s.peers){
const id = btoa(p.uri).replace(/=/g,'');
peerStore.set(id, p);
totalRx += Number(p.rx_rate || 0);
totalTx += Number(p.tx_rate || 0);
const isActive = (p.rx_rate > 0 || p.tx_rate > 0);
const isConnected = !!p.up;
const portStateClass = isActive ? 'active' : (isConnected ? 'connected' : '');
const portIconHtml = `<div class="port-icon-wrapper"><svg class="port-icon ${portStateClass}" viewBox="0 0 24 24"><rect class="port-body" x="4" y="6" width="16" height="12" rx="2" /><path class="port-body" d="M8 10h8v4H8z" /><circle class="port-led" cx="17" cy="9" r="1.5" /></svg></div>`;
peers.push(`<tr>
<td>${portIconHtml}</td>
<td class="uri-cell" title="${esc(p.uri)}">${esc(p.uri)}</td>
<td>${esc(p.remote)}</td>
<td style="font-weight:bold; color:${p.up?'var(--accent-orange)':'#aaa'}">${p.up?'UP':'DOWN'}</td>
<td class="ip-cell">${esc(p.ip)}</td>
<td>${esc(p.uptime)}</td>
<td>${esc(p.rtt)}</td>
<td>${esc(p.rx)}</td>
<td>${esc(p.tx)}</td>
<td class="net-green">⬇ ${fmtMbit(p.rx_rate)}</td>
<td style="color:var(--accent-orange)">⬆ ${fmtMbit(p.tx_rate)}</td>
<td><button onclick="peerAction('${id}','disconnect')">Disconnect</button></td>
</tr>`);
}
document.getElementById('peers').innerHTML = peers.join('');
const flows=[tHead(['IP','RX','TX','Uptime'])];
for(const f of s.sessions) {
flows.push(`<tr><td class="ip-cell">${esc(f.ip)}</td><td>${esc(f.rx)}</td><td>${esc(f.tx)}</td><td>${esc(f.uptime)}</td></tr>`);
}
document.getElementById('flows').innerHTML=flows.join('');
const paths=[tHead(['IP','Path'])];
for(const p of s.paths) paths.push(`<tr><td class="ip-cell">${esc(p.ip)}</td><td class="ip-cell">${esc(JSON.stringify(p.path))}</td></tr>`);
document.getElementById('paths').innerHTML=paths.join('');
const tree=[tHead(['IP','Parent'])];
for(const t of s.tree) tree.push(`<tr><td class="ip-cell">${esc(t.ip)}</td><td class="ip-cell">${esc(t.parent)}</td></tr>`);
document.getElementById('tree').innerHTML=tree.join('');
const downMbit = bytesPerSecToMbit(totalRx);
const upMbit = bytesPerSecToMbit(totalTx);
graphData.download.push(downMbit);
graphData.upload.push(upMbit);
if (graphData.download.length > graphData.maxPoints) {
graphData.download.shift();
graphData.upload.shift();
}
downSpeedEl.textContent = downMbit.toFixed(2) + ' Mbit/s';
upSpeedEl.textContent = upMbit.toFixed(2) + ' Mbit/s';
drawGraph();
}
setInterval(() => { reload().catch(console.error); }, 2000);
reload().catch(console.error);
window.addEventListener('resize', drawGraph);
</script>
</body>
</html>

67
cmd/yggdrasil/logcolor.go Normal file
View file

@ -0,0 +1,67 @@
package main
import (
"bytes"
"io"
"strings"
)
const (
colorReset = "\033[0m"
colorDim = "\033[2m"
colorCyan = "\033[36m"
colorBlue = "\033[94m"
colorGreen = "\033[32m"
colorYellow = "\033[33m"
colorRed = "\033[31m"
colorMagenta = "\033[35m"
)
type colorLineWriter struct {
w io.Writer
buf []byte
}
func newColorLineWriter(w io.Writer) *colorLineWriter {
return &colorLineWriter{w: w, buf: make([]byte, 0, 1024)}
}
func (c *colorLineWriter) Write(p []byte) (int, error) {
c.buf = append(c.buf, p...)
for {
i := bytes.IndexByte(c.buf, '\n')
if i < 0 {
break
}
line := c.buf[:i]
if _, err := c.w.Write([]byte(colorizeLine(string(line)) + "\n")); err != nil {
return 0, err
}
c.buf = c.buf[i+1:]
}
return len(p), nil
}
func colorizeLine(line string) string {
lower := strings.ToLower(line)
lineColor := colorCyan
switch {
case strings.Contains(lower, "error") || strings.Contains(lower, "failed") || strings.Contains(lower, "disconnected"):
lineColor = colorRed
case strings.Contains(lower, "warn"):
lineColor = colorYellow
case strings.Contains(lower, "connected") || strings.Contains(lower, "listening") || strings.Contains(lower, "started"):
lineColor = colorGreen
case strings.Contains(lower, "public key") || strings.Contains(lower, "ipv6") || strings.Contains(lower, "interface"):
lineColor = colorBlue
case strings.Contains(lower, "sandbox"):
lineColor = colorMagenta
}
if len(line) > 20 && line[4] == '/' && line[7] == '/' && line[10] == ' ' && line[13] == ':' && line[16] == ':' {
timestamp := line[:19]
rest := strings.TrimLeft(line[19:], " ")
return colorDim + timestamp + colorReset + " " + lineColor + rest + colorReset
}
return lineColor + line + colorReset
}

406
cmd/yggdrasil/main.go Normal file
View file

@ -0,0 +1,406 @@
package main
import (
"context"
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"net"
"os"
"os/signal"
"regexp"
"runtime"
"runtime/debug"
"strings"
"syscall"
"suah.dev/protect"
"github.com/gologme/log"
gsyslog "github.com/hashicorp/go-syslog"
"github.com/hjson/hjson-go/v4"
"github.com/kardianos/minwinsvc"
"github.com/yggdrasil-network/yggdrasil-go/src/address"
"github.com/yggdrasil-network/yggdrasil-go/src/admin"
"github.com/yggdrasil-network/yggdrasil-go/src/config"
"github.com/yggdrasil-network/yggdrasil-go/src/ipv6rwc"
"github.com/yggdrasil-network/yggdrasil-go/src/core"
"github.com/yggdrasil-network/yggdrasil-go/src/multicast"
"github.com/yggdrasil-network/yggdrasil-go/src/tun"
"github.com/yggdrasil-network/yggdrasil-go/src/version"
)
type node struct {
core *core.Core
tun *tun.TunAdapter
multicast *multicast.Multicast
admin *admin.AdminSocket
}
// The main function is responsible for configuring and starting Kaya.
func main() {
defaultThreads := runtime.NumCPU()
if defaultThreads < 1 {
defaultThreads = 1
}
genconf := flag.Bool("genconf", false, "print a new config to stdout")
useconf := flag.Bool("useconf", false, "read HJSON/JSON config from stdin")
useconffile := flag.String("useconffile", "", "read HJSON/JSON config from specified file path")
normaliseconf := flag.Bool("normaliseconf", false, "use in combination with either -useconf or -useconffile, outputs your configuration normalised")
exportkey := flag.Bool("exportkey", false, "use in combination with either -useconf or -useconffile, outputs your private key in PEM format")
confjson := flag.Bool("json", false, "print configuration from -genconf or -normaliseconf as JSON instead of HJSON")
autoconf := flag.Bool("autoconf", false, "automatic mode (dynamic IP, peer with IPv6 neighbors)")
ver := flag.Bool("version", false, "prints the version of this build")
logto := flag.String("logto", "stdout", "file path to log to, \"syslog\" or \"stdout\"")
getaddr := flag.Bool("address", false, "use in combination with either -useconf or -useconffile, outputs your IPv6 address")
getsnet := flag.Bool("subnet", false, "use in combination with either -useconf or -useconffile, outputs your IPv6 subnet")
getpkey := flag.Bool("publickey", false, "use in combination with either -useconf or -useconffile, outputs your public key")
loglevel := flag.String("loglevel", "info", "loglevel to enable")
threads := flag.Int("threads", defaultThreads, "number of scheduler threads to run (defaults to one per CPU core)")
maxThreads := flag.Int("max-threads", 0, "maximum total OS threads for the Go runtime (0 keeps runtime default)")
sandbox := flag.Bool("sandbox", true, "enable runtime sandbox hardening where supported")
dashboardListen := flag.String("dashboard-listen", "", "HTTP dashboard listen address, e.g. 127.0.0.1:8080")
publicInterface := flag.String("public-interface", "", "public read-only dashboard listen address, e.g. 0.0.0.0:8081")
dashboardUser := flag.String("dashboard-user", "", "HTTP dashboard username for basic auth")
dashboardPassword := flag.String("dashboard-password", "", "HTTP dashboard password for basic auth")
chuserto := flag.String("user", "", "user (and, optionally, group) to set UID/GID to")
flag.Parse()
if *threads < 1 {
panic("--threads must be >= 1")
}
if *maxThreads < 0 {
panic("--max-threads must be >= 0")
}
runtime.GOMAXPROCS(*threads)
if *maxThreads > 0 {
debug.SetMaxThreads(*maxThreads)
}
done := make(chan struct{})
defer close(done)
// Catch interrupts from the operating system to exit gracefully.
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
// Create a new logger that logs output to stdout.
var logger *log.Logger
switch *logto {
case "stdout":
var outWriter io.Writer = os.Stdout
if info, err := os.Stdout.Stat(); err == nil && (info.Mode()&os.ModeCharDevice) != 0 {
outWriter = newColorLineWriter(os.Stdout)
}
logger = log.New(outWriter, "", log.Flags())
case "syslog":
if syslogger, err := gsyslog.NewLogger(gsyslog.LOG_NOTICE, "DAEMON", version.BuildName()); err == nil {
logger = log.New(syslogger, "", log.Flags()&^(log.Ldate|log.Ltime))
}
default:
if logfd, err := os.OpenFile(*logto, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
logger = log.New(logfd, "", log.Flags())
}
}
if logger == nil {
logger = log.New(os.Stdout, "", log.Flags())
logger.Warnln("Logging defaulting to stdout")
}
if *normaliseconf {
setLogLevel("error", logger)
} else {
setLogLevel(*loglevel, logger)
}
cfg := config.GenerateConfig()
var err error
switch {
case *ver:
fmt.Println("Build name:", version.BuildName())
fmt.Println("Build version:", version.BuildVersion())
return
case *autoconf:
// Use an autoconf-generated config, this will give us random keys and
// port numbers, and will use an automatically selected TUN interface.
case *useconf:
if _, err := cfg.ReadFrom(os.Stdin); err != nil {
panic(err)
}
case *useconffile != "":
f, err := os.Open(*useconffile)
if err != nil {
panic(err)
}
if _, err := cfg.ReadFrom(f); err != nil {
panic(err)
}
_ = f.Close()
case *genconf:
cfg.AdminListen = ""
var bs []byte
if *confjson {
bs, err = json.MarshalIndent(cfg, "", " ")
} else {
bs, err = hjson.Marshal(cfg)
}
if err != nil {
panic(err)
}
fmt.Println(string(bs))
return
default:
fmt.Println("Usage:")
flag.PrintDefaults()
if *getaddr || *getsnet {
fmt.Println("\nError: You need to specify some config data using -useconf or -useconffile.")
}
return
}
privateKey := ed25519.PrivateKey(cfg.PrivateKey)
publicKey := privateKey.Public().(ed25519.PublicKey)
switch {
case *getaddr:
addr := address.AddrForKey(publicKey)
ip := net.IP(addr[:])
fmt.Println(ip.String())
return
case *getsnet:
snet := address.SubnetForKey(publicKey)
ipnet := net.IPNet{
IP: append(snet[:], 0, 0, 0, 0, 0, 0, 0, 0),
Mask: net.CIDRMask(len(snet)*8, 128),
}
fmt.Println(ipnet.String())
return
case *getpkey:
fmt.Println(hex.EncodeToString(publicKey))
return
case *normaliseconf:
cfg.AdminListen = ""
if cfg.PrivateKeyPath != "" {
cfg.PrivateKey = nil
}
var bs []byte
if *confjson {
bs, err = json.MarshalIndent(cfg, "", " ")
} else {
bs, err = hjson.Marshal(cfg)
}
if err != nil {
panic(err)
}
fmt.Println(string(bs))
return
case *exportkey:
pem, err := cfg.MarshalPEMPrivateKey()
if err != nil {
panic(err)
}
fmt.Println(string(pem))
return
}
if *maxThreads > 0 {
logger.Infof("Runtime threading: GOMAXPROCS=%d, max OS threads=%d", *threads, *maxThreads)
} else {
logger.Infof("Runtime threading: GOMAXPROCS=%d", *threads)
}
if err := applySandbox(*sandbox, logger); err != nil {
panic(err)
}
n := &node{}
// Set up the Kaya node itself.
{
iprange := net.IPNet{
IP: net.ParseIP("200::"),
Mask: net.CIDRMask(7, 128),
}
options := []core.SetupOption{
core.NodeInfo(cfg.NodeInfo),
core.NodeInfoPrivacy(cfg.NodeInfoPrivacy),
core.PeerFilter(func(ip net.IP) bool {
return !iprange.Contains(ip)
}),
}
for _, addr := range cfg.Listen {
options = append(options, core.ListenAddress(addr))
}
for _, peer := range cfg.Peers {
options = append(options, core.Peer{URI: peer})
}
for intf, peers := range cfg.InterfacePeers {
for _, peer := range peers {
options = append(options, core.Peer{URI: peer, SourceInterface: intf})
}
}
for _, allowed := range cfg.AllowedPublicKeys {
k, err := hex.DecodeString(allowed)
if err != nil {
panic(err)
}
options = append(options, core.AllowedPublicKey(k[:]))
}
if n.core, err = core.New(cfg.Certificate, logger, options...); err != nil {
panic(err)
}
address, subnet := n.core.Address(), n.core.Subnet()
logger.Printf("Your public key is %s", hex.EncodeToString(n.core.PublicKey()))
logger.Printf("Your IPv6 address is %s", address.String())
logger.Printf("Your IPv6 subnet is %s", subnet.String())
}
// Set up the admin socket.
{
options := []admin.SetupOption{
admin.ListenAddress(cfg.AdminListen),
}
if cfg.LogLookups {
options = append(options, admin.LogLookups{})
}
if n.admin, err = admin.New(n.core, logger, options...); err != nil {
panic(err)
}
if n.admin != nil {
n.admin.SetupAdminHandlers()
}
}
// Set up the multicast module.
{
options := []multicast.SetupOption{}
for _, intf := range cfg.MulticastInterfaces {
options = append(options, multicast.MulticastInterface{
Regex: regexp.MustCompile(intf.Regex),
Beacon: intf.Beacon,
Listen: intf.Listen,
Port: intf.Port,
Priority: uint8(intf.Priority),
Password: intf.Password,
})
}
if n.multicast, err = multicast.New(n.core, logger, options...); err != nil {
panic(err)
}
if n.admin != nil && n.multicast != nil {
n.multicast.SetupAdminHandlers(n.admin)
}
}
// Set up the TUN module.
{
options := []tun.SetupOption{
tun.InterfaceName(cfg.IfName),
tun.InterfaceMTU(cfg.IfMTU),
}
if n.tun, err = tun.New(ipv6rwc.NewReadWriteCloser(n.core), logger, options...); err != nil {
panic(err)
}
if n.admin != nil && n.tun != nil {
n.tun.SetupAdminHandlers(n.admin)
}
}
dashboards := make([]*dashboardServer, 0, 2)
if dashboard, err := startDashboard(n, logger, *dashboardListen, *dashboardUser, *dashboardPassword, false); err != nil {
panic(err)
} else if dashboard != nil {
dashboards = append(dashboards, dashboard)
}
if publicDashboard, err := startDashboard(n, logger, *publicInterface, "", "", true); err != nil {
panic(err)
} else if publicDashboard != nil {
dashboards = append(dashboards, publicDashboard)
}
defer func() {
for _, dashboard := range dashboards {
if dashboard != nil {
dashboard.stop()
}
}
}()
//Windows service shutdown
minwinsvc.SetOnExit(func() {
logger.Infof("Shutting down service ...")
cancel()
// Wait for all parts to shutdown properly
<-done
})
// Change user if requested
if *chuserto != "" {
err = chuser(*chuserto)
if err != nil {
panic(err)
}
}
// Promise final modes of operation. At this point, if at all:
// - raw socket is created/open
// - admin socket is created/open
// - privileges are dropped to non-root user
//
// Peers, InterfacePeers, Listen can be UNIX sockets;
// Go's net.Listen.Close() deletes files on shutdown.
promises := []string{"stdio", "rpath", "cpath", "inet", "unix", "dns"}
if len(cfg.MulticastInterfaces) > 0 {
promises = append(promises, "mcast")
}
if err := protect.Pledge(strings.Join(promises, " ")); err != nil {
panic(fmt.Sprintf("pledge: %v: %v", promises, err))
}
// Block until we are told to shut down.
<-ctx.Done()
// Shut down the node.
_ = n.admin.Stop()
_ = n.multicast.Stop()
_ = n.tun.Stop()
n.core.Stop()
}
func setLogLevel(loglevel string, logger *log.Logger) {
levels := [...]string{"error", "warn", "info", "debug", "trace"}
loglevel = strings.ToLower(loglevel)
contains := func() bool {
for _, l := range levels {
if l == loglevel {
return true
}
}
return false
}
if !contains() { // set default log level
logger.Infoln("Loglevel parse failed. Set default level(info)")
loglevel = "info"
}
for _, l := range levels {
logger.EnableLevel(l)
if l == loglevel {
break
}
}
}

View file

@ -0,0 +1,27 @@
//go:build linux
package main
import (
"fmt"
"github.com/gologme/log"
"golang.org/x/sys/unix"
)
func applySandbox(enabled bool, logger *log.Logger) error {
if !enabled {
return nil
}
if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
return fmt.Errorf("failed to enable no_new_privs: %w", err)
}
if err := unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil {
return fmt.Errorf("failed to disable dumpable state: %w", err)
}
if err := unix.Setrlimit(unix.RLIMIT_CORE, &unix.Rlimit{Cur: 0, Max: 0}); err != nil {
return fmt.Errorf("failed to disable core dumps: %w", err)
}
logger.Infoln("Linux sandbox hardening enabled: no_new_privs, non-dumpable, core dumps disabled")
return nil
}

View file

@ -0,0 +1,9 @@
//go:build !linux
package main
import "github.com/gologme/log"
func applySandbox(_ bool, _ *log.Logger) error {
return nil
}