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

93
cmd/genkeys/main.go Normal file
View file

@ -0,0 +1,93 @@
/*
This file generates crypto keys.
It prints out a new set of keys each time if finds a "better" one.
By default, "better" means a higher NodeID (-> higher IP address).
This is because the IP address format can compress leading 1s in the address, to increase the number of ID bits in the address.
If run with the "-sig" flag, it generates signing keys instead.
A "better" signing key means one with a higher TreeID.
This only matters if it's high enough to make you the root of the tree.
*/
package main
import (
"crypto/ed25519"
"encoding/hex"
"fmt"
"net"
"runtime"
"time"
"suah.dev/protect"
"github.com/yggdrasil-network/yggdrasil-go/src/address"
)
type keySet struct {
priv ed25519.PrivateKey
pub ed25519.PublicKey
count uint64
}
func main() {
if err := protect.Pledge("stdio"); err != nil {
panic(err)
}
threads := runtime.GOMAXPROCS(0)
fmt.Println("Threads:", threads)
start := time.Now()
var totalKeys uint64
totalKeys = 0
var currentBest ed25519.PublicKey
newKeys := make(chan keySet, threads)
for i := 0; i < threads; i++ {
go doKeys(newKeys)
}
for {
newKey := <-newKeys
if isBetter(currentBest, newKey.pub) || len(currentBest) == 0 {
totalKeys += newKey.count
currentBest = newKey.pub
fmt.Println("-----", time.Since(start), "---", totalKeys, "keys tried")
fmt.Println("Priv:", hex.EncodeToString(newKey.priv))
fmt.Println("Pub:", hex.EncodeToString(newKey.pub))
addr := address.AddrForKey(newKey.pub)
fmt.Println("IP:", net.IP(addr[:]).String())
}
}
}
func isBetter(oldPub, newPub ed25519.PublicKey) bool {
for idx := range oldPub {
if newPub[idx] < oldPub[idx] {
return true
}
if newPub[idx] > oldPub[idx] {
break
}
}
return false
}
func doKeys(out chan<- keySet) {
bestKey := make(ed25519.PublicKey, ed25519.PublicKeySize)
var count uint64
count = 0
for idx := range bestKey {
bestKey[idx] = 0xff
}
for {
pub, priv, err := ed25519.GenerateKey(nil)
count++
if err != nil {
panic(err)
}
if !isBetter(bestKey, pub) {
continue
}
bestKey = pub
out <- keySet{priv, pub, count}
count = 0
}
}

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
}

View file

@ -0,0 +1,91 @@
package main
import (
"bytes"
"flag"
"fmt"
"log"
"os"
"github.com/hjson/hjson-go/v4"
"golang.org/x/text/encoding/unicode"
"github.com/yggdrasil-network/yggdrasil-go/src/config"
)
type CmdLineEnv struct {
args []string
endpoint, server string
injson, borders, ver bool
}
func newCmdLineEnv() CmdLineEnv {
var cmdLineEnv CmdLineEnv
cmdLineEnv.endpoint = config.GetDefaults().DefaultAdminListen
return cmdLineEnv
}
func (cmdLineEnv *CmdLineEnv) parseFlagsAndArgs() {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [options] command [key=value] [key=value] ...\n\n", os.Args[0])
fmt.Println("Options:")
flag.PrintDefaults()
fmt.Println()
fmt.Println("Please note that options must always specified BEFORE the command\non the command line or they will be ignored.")
fmt.Println()
fmt.Println("Commands:\n - Use \"list\" for a list of available commands")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" - ", os.Args[0], "list")
fmt.Println(" - ", os.Args[0], "getPeers")
fmt.Println(" - ", os.Args[0], "-endpoint=tcp://localhost:9001 getPeers")
fmt.Println(" - ", os.Args[0], "-endpoint=unix:///var/run/ygg.sock getPeers")
}
server := flag.String("endpoint", cmdLineEnv.endpoint, "Admin socket endpoint")
injson := flag.Bool("json", false, "Output in JSON format (as opposed to pretty-print)")
borders := flag.Bool("borders", true, "Output borders on tables")
ver := flag.Bool("version", false, "Prints the version of this build")
flag.Parse()
cmdLineEnv.args = flag.Args()
cmdLineEnv.server = *server
cmdLineEnv.injson = *injson
cmdLineEnv.borders = *borders
cmdLineEnv.ver = *ver
}
func (cmdLineEnv *CmdLineEnv) setEndpoint(logger *log.Logger) {
if cmdLineEnv.server == cmdLineEnv.endpoint {
if cfg, err := os.ReadFile(config.GetDefaults().DefaultConfigFile); err == nil {
if bytes.Equal(cfg[0:2], []byte{0xFF, 0xFE}) ||
bytes.Equal(cfg[0:2], []byte{0xFE, 0xFF}) {
utf := unicode.UTF16(unicode.BigEndian, unicode.UseBOM)
decoder := utf.NewDecoder()
cfg, err = decoder.Bytes(cfg)
if err != nil {
panic(err)
}
}
var dat map[string]interface{}
if err := hjson.Unmarshal(cfg, &dat); err != nil {
panic(err)
}
if ep, ok := dat["AdminListen"].(string); ok && (ep != "none" && ep != "") {
cmdLineEnv.endpoint = ep
logger.Println("Found platform default config file", config.GetDefaults().DefaultConfigFile)
logger.Println("Using endpoint", cmdLineEnv.endpoint, "from AdminListen")
} else {
logger.Println("Configuration file doesn't contain appropriate AdminListen option")
logger.Println("Falling back to platform default", config.GetDefaults().DefaultAdminListen)
}
} else {
logger.Println("Can't open config file from default location", config.GetDefaults().DefaultConfigFile)
logger.Println("Falling back to platform default", config.GetDefaults().DefaultAdminListen)
}
} else {
cmdLineEnv.endpoint = cmdLineEnv.server
logger.Println("Using endpoint", cmdLineEnv.endpoint, "from command line")
}
}

411
cmd/yggdrasilctl/main.go Normal file
View file

@ -0,0 +1,411 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"net"
"net/url"
"os"
"sort"
"strings"
"time"
"suah.dev/protect"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/renderer"
"github.com/olekukonko/tablewriter/tw"
"github.com/yggdrasil-network/yggdrasil-go/src/admin"
"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"
)
func main() {
// read config, speak DNS/TCP and/or over a UNIX socket
if err := protect.Pledge("stdio rpath inet unix dns"); err != nil {
panic(err)
}
// makes sure we can use defer and still return an error code to the OS
os.Exit(run())
}
func run() int {
logbuffer := &bytes.Buffer{}
logger := log.New(logbuffer, "", log.Flags())
defer func() int {
if r := recover(); r != nil {
logger.Println("Fatal error:", r)
fmt.Print(logbuffer)
return 1
}
return 0
}()
cmdLineEnv := newCmdLineEnv()
cmdLineEnv.parseFlagsAndArgs()
if cmdLineEnv.ver {
fmt.Println("Build name:", version.BuildName())
fmt.Println("Build version:", version.BuildVersion())
fmt.Println("To get the version number of the running Kaya node, run", os.Args[0], "getSelf")
return 0
}
if len(cmdLineEnv.args) == 0 {
flag.Usage()
return 0
}
cmdLineEnv.setEndpoint(logger)
var conn net.Conn
u, err := url.Parse(cmdLineEnv.endpoint)
if err == nil {
switch strings.ToLower(u.Scheme) {
case "unix":
logger.Println("Connecting to UNIX socket", cmdLineEnv.endpoint[7:])
conn, err = net.Dial("unix", cmdLineEnv.endpoint[7:])
case "tcp":
logger.Println("Connecting to TCP socket", u.Host)
conn, err = net.Dial("tcp", u.Host)
default:
logger.Println("Unknown protocol or malformed address - check your endpoint")
err = errors.New("protocol not supported")
}
} else {
logger.Println("Connecting to TCP socket", u.Host)
conn, err = net.Dial("tcp", cmdLineEnv.endpoint)
}
if err != nil {
panic(err)
}
// config and socket are done, work without unprivileges
if err := protect.Pledge("stdio"); err != nil {
panic(err)
}
logger.Println("Connected")
defer conn.Close()
decoder := json.NewDecoder(conn)
encoder := json.NewEncoder(conn)
send := &admin.AdminSocketRequest{}
recv := &admin.AdminSocketResponse{}
args := map[string]string{}
for c, a := range cmdLineEnv.args {
if c == 0 {
if strings.HasPrefix(a, "-") {
logger.Printf("Ignoring flag %s as it should be specified before other parameters\n", a)
continue
}
logger.Printf("Sending request: %v\n", a)
send.Name = a
continue
}
tokens := strings.SplitN(a, "=", 2)
switch {
case len(tokens) == 1:
logger.Println("Ignoring invalid argument:", a)
default:
args[tokens[0]] = tokens[1]
}
}
if send.Arguments, err = json.Marshal(args); err != nil {
panic(err)
}
if err := encoder.Encode(&send); err != nil {
panic(err)
}
logger.Printf("Request sent")
if err := decoder.Decode(&recv); err != nil {
panic(err)
}
if recv.Status == "error" {
if err := recv.Error; err != "" {
fmt.Println("Admin socket returned an error:", err)
} else {
fmt.Println("Admin socket returned an error but didn't specify any error text")
}
return 1
}
if cmdLineEnv.injson {
if json, err := json.MarshalIndent(recv.Response, "", " "); err == nil {
fmt.Println(string(json))
}
return 0
}
opts := []tablewriter.Option{
tablewriter.WithRowAlignment(tw.AlignLeft),
tablewriter.WithHeaderAlignment(tw.AlignCenter),
tablewriter.WithHeaderAutoFormat(tw.Off),
tablewriter.WithDebug(false),
}
if !cmdLineEnv.borders {
opts = append(opts, tablewriter.WithRenderer(renderer.NewBlueprint(tw.Rendition{
Borders: tw.BorderNone,
Settings: tw.Settings{
Lines: tw.LinesNone,
Separators: tw.SeparatorsNone,
},
})))
}
table := tablewriter.NewTable(os.Stdout, opts...)
switch strings.ToLower(send.Name) {
case "list":
var resp admin.ListResponse
if err := json.Unmarshal(recv.Response, &resp); err != nil {
panic(err)
}
table.Header([]string{"Command", "Arguments", "Description"})
for _, entry := range resp.List {
for i := range entry.Fields {
entry.Fields[i] = entry.Fields[i] + "=..."
}
_ = table.Append([]string{entry.Command, strings.Join(entry.Fields, ", "), entry.Description})
}
_ = table.Render()
case "getself":
var resp admin.GetSelfResponse
if err := json.Unmarshal(recv.Response, &resp); err != nil {
panic(err)
}
_ = table.Append([]string{"Build name:", resp.BuildName})
_ = table.Append([]string{"Build version:", resp.BuildVersion})
_ = table.Append([]string{"IPv6 address:", resp.IPAddress})
_ = table.Append([]string{"IPv6 subnet:", resp.Subnet})
_ = table.Append([]string{"Routing table size:", fmt.Sprintf("%d", resp.RoutingEntries)})
_ = table.Append([]string{"Public key:", resp.PublicKey})
_ = table.Render()
case "getpeers":
var resp admin.GetPeersResponse
if err := json.Unmarshal(recv.Response, &resp); err != nil {
panic(err)
}
table.Header([]string{"URI", "Remote", "State", "Dir", "IP Address", "Uptime", "RTT", "RX", "TX", "Down", "Up", "Pr", "Cost", "Last Error"})
for _, peer := range resp.Peers {
state, lasterr, dir, rtt, rxr, txr := "Up", "-", "Out", "-", "-", "-"
if !peer.Up {
if state = "Down"; peer.LastError != "" {
lasterr = fmt.Sprintf("%s ago: %s", peer.LastErrorTime.Round(time.Second), peer.LastError)
}
} else if rttms := float64(peer.Latency.Microseconds()) / 1000; rttms > 0 {
rtt = fmt.Sprintf("%.02fms", rttms)
}
if peer.Inbound {
dir = "In"
}
parsedURI, parseErr := url.Parse(peer.URI)
uristring := peer.URI
if parseErr == nil {
parsedURI.RawQuery = ""
uristring = parsedURI.String()
}
if peer.RXRate > 0 {
rxr = peer.RXRate.String() + "/s"
}
if peer.TXRate > 0 {
txr = peer.TXRate.String() + "/s"
}
remote := peerRemoteHost(parsedURI, parseErr)
_ = table.Append([]string{
uristring,
remote,
state,
dir,
peer.IPAddress,
(time.Duration(peer.Uptime) * time.Second).String(),
rtt,
peer.RXBytes.String(),
peer.TXBytes.String(),
rxr,
txr,
fmt.Sprintf("%d", peer.Priority),
fmt.Sprintf("%d", peer.Cost),
lasterr,
})
}
_ = table.Render()
case "gettree":
var resp admin.GetTreeResponse
if err := json.Unmarshal(recv.Response, &resp); err != nil {
panic(err)
}
table.Header([]string{"Tree", "IP Address", "Sequence"})
for _, line := range formatTreeRows(resp.Tree) {
_ = table.Append([]string{line.Branch, line.IPAddress, fmt.Sprintf("%d", line.Sequence)})
}
_ = table.Render()
case "getpaths":
var resp admin.GetPathsResponse
if err := json.Unmarshal(recv.Response, &resp); err != nil {
panic(err)
}
table.Header([]string{"Public Key", "IP Address", "Path", "Seq"})
for _, p := range resp.Paths {
_ = table.Append([]string{
p.PublicKey,
p.IPAddress,
fmt.Sprintf("%v", p.Path),
fmt.Sprintf("%d", p.Sequence),
})
}
_ = table.Render()
case "getsessions":
var resp admin.GetSessionsResponse
if err := json.Unmarshal(recv.Response, &resp); err != nil {
panic(err)
}
table.Header([]string{"Public Key", "IP Address", "Uptime", "RX", "TX"})
for _, p := range resp.Sessions {
_ = table.Append([]string{
p.PublicKey,
p.IPAddress,
(time.Duration(p.Uptime) * time.Second).String(),
p.RXBytes.String(),
p.TXBytes.String(),
})
}
_ = table.Render()
case "getnodeinfo":
var resp core.GetNodeInfoResponse
if err := json.Unmarshal(recv.Response, &resp); err != nil {
panic(err)
}
for _, v := range resp {
fmt.Println(string(v))
break
}
case "getmulticastinterfaces":
var resp multicast.GetMulticastInterfacesResponse
if err := json.Unmarshal(recv.Response, &resp); err != nil {
panic(err)
}
fmtBool := func(b bool) string {
if b {
return "Yes"
}
return "-"
}
table.Header([]string{"Name", "Listen Address", "Beacon", "Listen", "Password"})
for _, p := range resp.Interfaces {
_ = table.Append([]string{
p.Name,
p.Address,
fmtBool(p.Beacon),
fmtBool(p.Listen),
fmtBool(p.Password),
})
}
_ = table.Render()
case "gettun":
var resp tun.GetTUNResponse
if err := json.Unmarshal(recv.Response, &resp); err != nil {
panic(err)
}
_ = table.Append([]string{"TUN enabled:", fmt.Sprintf("%#v", resp.Enabled)})
if resp.Enabled {
_ = table.Append([]string{"Interface name:", resp.Name})
_ = table.Append([]string{"Interface MTU:", fmt.Sprintf("%d", resp.MTU)})
}
_ = table.Render()
case "addpeer", "removepeer", "setpeertraffic":
default:
fmt.Println(string(recv.Response))
}
return 0
}
func peerRemoteHost(parsedURI *url.URL, parseErr error) string {
if parseErr != nil || parsedURI == nil {
return "-"
}
host := parsedURI.Host
if splitHost, _, err := net.SplitHostPort(host); err == nil {
return splitHost
}
if host == "" {
return "-"
}
return host
}
type treeLine struct {
Branch string
IPAddress string
Sequence uint64
}
func formatTreeRows(entries []admin.TreeEntry) []treeLine {
if len(entries) == 0 {
return nil
}
children := make(map[string][]admin.TreeEntry, len(entries))
byKey := make(map[string]admin.TreeEntry, len(entries))
for _, entry := range entries {
byKey[entry.PublicKey] = entry
}
roots := make([]admin.TreeEntry, 0, len(entries))
for _, entry := range entries {
if entry.Parent == "" || entry.Parent == entry.PublicKey || byKey[entry.Parent].PublicKey == "" {
roots = append(roots, entry)
continue
}
children[entry.Parent] = append(children[entry.Parent], entry)
}
sort.Slice(roots, func(i, j int) bool { return roots[i].PublicKey < roots[j].PublicKey })
for parent := range children {
sort.Slice(children[parent], func(i, j int) bool {
return children[parent][i].PublicKey < children[parent][j].PublicKey
})
}
rows := make([]treeLine, 0, len(entries))
var walk func(admin.TreeEntry, string, bool)
walk = func(node admin.TreeEntry, prefix string, last bool) {
branchPrefix := ""
nextPrefix := ""
if prefix != "" {
if last {
branchPrefix = prefix + "└─ "
nextPrefix = prefix + " "
} else {
branchPrefix = prefix + "├─ "
nextPrefix = prefix + "│ "
}
}
label := node.PublicKey
if len(label) > 16 {
label = label[:16] + "…"
}
rows = append(rows, treeLine{Branch: branchPrefix + label, IPAddress: node.IPAddress, Sequence: node.Sequence})
kids := children[node.PublicKey]
for i, child := range kids {
walk(child, nextPrefix, i == len(kids)-1)
}
}
for i, root := range roots {
walk(root, "", i == len(roots)-1)
}
return rows
}