diff --git a/cmd/yggdrasil/main.go b/cmd/yggdrasil/main.go index d66ef779..36eedb41 100644 --- a/cmd/yggdrasil/main.go +++ b/cmd/yggdrasil/main.go @@ -309,6 +309,12 @@ func main() { } n.webui = webui.Server(listenAddr, cfg.WebUI.Password, logger) + + // Connect WebUI with AdminSocket for direct API access + if n.admin != nil { + n.webui.SetAdmin(n.admin) + } + if cfg.WebUI.Password != "" { logger.Infof("WebUI password authentication enabled") } else { diff --git a/src/admin/admin.go b/src/admin/admin.go index 54c1a124..7f0a238f 100644 --- a/src/admin/admin.go +++ b/src/admin/admin.go @@ -277,6 +277,43 @@ func (a *AdminSocket) Stop() error { return nil } +// CallHandler calls an admin handler directly by name without using socket +func (a *AdminSocket) CallHandler(name string, args json.RawMessage) (interface{}, error) { + if a == nil { + return nil, errors.New("admin socket not initialized") + } + + reqname := strings.ToLower(name) + handler, ok := a.handlers[reqname] + if !ok { + return nil, fmt.Errorf("unknown action '%s', try 'list' for help", reqname) + } + + return handler.handler(args) +} + +// GetAvailableCommands returns list of available admin commands +func (a *AdminSocket) GetAvailableCommands() []ListEntry { + if a == nil { + return nil + } + + var list []ListEntry + for name, handler := range a.handlers { + list = append(list, ListEntry{ + Command: name, + Description: handler.desc, + Fields: handler.args, + }) + } + + sort.SliceStable(list, func(i, j int) bool { + return strings.Compare(list[i].Command, list[j].Command) < 0 + }) + + return list +} + // listen is run by start and manages API connections. func (a *AdminSocket) listen() { defer a.listener.Close() diff --git a/src/config/config.go b/src/config/config.go index 5f4d965b..0358da8b 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -40,18 +40,18 @@ import ( // options that are necessary for an Yggdrasil node to run. You will need to // supply one of these structs to the Yggdrasil core when starting a node. type NodeConfig struct { - PrivateKey KeyBytes `comment:"Your private key. DO NOT share this with anyone!"` - PrivateKeyPath string `comment:"The path to your private key file in PEM format."` + PrivateKey KeyBytes `json:",omitempty" comment:"Your private key. DO NOT share this with anyone!"` + PrivateKeyPath string `json:",omitempty" comment:"The path to your private key file in PEM format."` Certificate *tls.Certificate `json:"-"` Peers []string `comment:"List of outbound peer connection strings (e.g. tls://a.b.c.d:e or\nsocks://a.b.c.d:e/f.g.h.i:j). Connection strings can contain options,\nsee https://yggdrasil-network.github.io/configurationref.html#peers.\nYggdrasil has no concept of bootstrap nodes - all network traffic\nwill transit peer connections. Therefore make sure to only peer with\nnearby nodes that have good connectivity and low latency. Avoid adding\npeers to this list from distant countries as this will worsen your\nnode's connectivity and performance considerably."` InterfacePeers map[string][]string `comment:"List of connection strings for outbound peer connections in URI format,\narranged by source interface, e.g. { \"eth0\": [ \"tls://a.b.c.d:e\" ] }.\nYou should only use this option if your machine is multi-homed and you\nwant to establish outbound peer connections on different interfaces.\nOtherwise you should use \"Peers\"."` Listen []string `comment:"Listen addresses for incoming connections. You will need to add\nlisteners in order to accept incoming peerings from non-local nodes.\nThis is not required if you wish to establish outbound peerings only.\nMulticast peer discovery will work regardless of any listeners set\nhere. Each listener should be specified in URI format as above, e.g.\ntls://0.0.0.0:0 or tls://[::]:0 to listen on all interfaces."` - AdminListen string `comment:"Listen address for admin connections. Default is to listen for local\nconnections either on TCP/9001 or a UNIX socket depending on your\nplatform. Use this value for yggdrasilctl -endpoint=X. To disable\nthe admin socket, use the value \"none\" instead."` + AdminListen string `json:",omitempty" comment:"Listen address for admin connections. Default is to listen for local\nconnections either on TCP/9001 or a UNIX socket depending on your\nplatform. Use this value for yggdrasilctl -endpoint=X. To disable\nthe admin socket, use the value \"none\" instead."` MulticastInterfaces []MulticastInterfaceConfig `comment:"Configuration for which interfaces multicast peer discovery should be\nenabled on. Regex is a regular expression which is matched against an\ninterface name, and interfaces use the first configuration that they\nmatch against. Beacon controls whether or not your node advertises its\npresence to others, whereas Listen controls whether or not your node\nlistens out for and tries to connect to other advertising nodes. See\nhttps://yggdrasil-network.github.io/configurationref.html#multicastinterfaces\nfor more supported options."` AllowedPublicKeys []string `comment:"List of peer public keys to allow incoming peering connections\nfrom. If left empty/undefined then all connections will be allowed\nby default. This does not affect outgoing peerings, nor does it\naffect link-local peers discovered via multicast.\nWARNING: THIS IS NOT A FIREWALL and DOES NOT limit who can reach\nopen ports or services running on your machine!"` IfName string `comment:"Local network interface name for TUN adapter, or \"auto\" to select\nan interface automatically, or \"none\" to run without TUN."` IfMTU uint64 `comment:"Maximum Transmission Unit (MTU) size for your local TUN interface.\nDefault is the largest supported size for your platform. The lowest\npossible value is 1280."` - LogLookups bool `comment:"Log lookups for peers and nodes. This is useful for debugging and\nmonitoring the network. It is disabled by default."` + LogLookups bool `json:",omitempty" comment:"Log lookups for peers and nodes. This is useful for debugging and\nmonitoring the network. It is disabled by default."` NodeInfoPrivacy bool `comment:"By default, nodeinfo contains some defaults including the platform,\narchitecture and Yggdrasil version. These can help when surveying\nthe network and diagnosing network routing problems. Enabling\nnodeinfo privacy prevents this, so that only items specified in\n\"NodeInfo\" are sent back if specified."` NodeInfo map[string]interface{} `comment:"Optional nodeinfo. This must be a { \"key\": \"value\", ... } map\nor set as null. This is entirely optional but, if set, is visible\nto the whole network on request."` WebUI WebUIConfig `comment:"Web interface configuration for managing the node through a browser."` @@ -82,7 +82,6 @@ func GenerateConfig() *NodeConfig { // Create a node configuration and populate it. cfg := new(NodeConfig) cfg.NewPrivateKey() - cfg.PrivateKeyPath = "" cfg.Listen = []string{} cfg.AdminListen = defaults.DefaultAdminListen cfg.Peers = []string{} diff --git a/src/webui/server.go b/src/webui/server.go index 11957ab2..da2bdffd 100644 --- a/src/webui/server.go +++ b/src/webui/server.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/yggdrasil-network/yggdrasil-go/src/admin" "github.com/yggdrasil-network/yggdrasil-go/src/core" ) @@ -24,6 +25,7 @@ type WebUIServer struct { sessionsMux sync.RWMutex failedAttempts map[string]*FailedLoginInfo // IP -> failed login info attemptsMux sync.RWMutex + admin *admin.AdminSocket // Admin socket reference for direct API calls } type LoginRequest struct { @@ -49,9 +51,15 @@ func Server(listen string, password string, log core.Logger) *WebUIServer { log: log, sessions: make(map[string]time.Time), failedAttempts: make(map[string]*FailedLoginInfo), + admin: nil, // Will be set later via SetAdmin } } +// SetAdmin sets the admin socket reference for direct API calls +func (w *WebUIServer) SetAdmin(admin *admin.AdminSocket) { + w.admin = admin +} + // generateSessionID creates a random session ID func (w *WebUIServer) generateSessionID() string { bytes := make([]byte, 32) @@ -299,6 +307,67 @@ func (w *WebUIServer) logoutHandler(rw http.ResponseWriter, r *http.Request) { http.Redirect(rw, r, "/login.html", http.StatusSeeOther) } +// adminAPIHandler handles direct admin API calls +func (w *WebUIServer) adminAPIHandler(rw http.ResponseWriter, r *http.Request) { + if w.admin == nil { + http.Error(rw, "Admin API not available", http.StatusServiceUnavailable) + return + } + + // Extract command from URL path + // /api/admin/getSelf -> getSelf + path := strings.TrimPrefix(r.URL.Path, "/api/admin/") + command := strings.Split(path, "/")[0] + + if command == "" { + // Return list of available commands + commands := w.admin.GetAvailableCommands() + rw.Header().Set("Content-Type", "application/json") + json.NewEncoder(rw).Encode(map[string]interface{}{ + "status": "success", + "commands": commands, + }) + return + } + + var args map[string]interface{} + if r.Method == http.MethodPost { + if err := json.NewDecoder(r.Body).Decode(&args); err != nil { + args = make(map[string]interface{}) + } + } else { + args = make(map[string]interface{}) + } + + // Call admin handler directly + result, err := w.callAdminHandler(command, args) + if err != nil { + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(http.StatusBadRequest) + json.NewEncoder(rw).Encode(map[string]interface{}{ + "status": "error", + "error": err.Error(), + }) + return + } + + rw.Header().Set("Content-Type", "application/json") + json.NewEncoder(rw).Encode(map[string]interface{}{ + "status": "success", + "response": result, + }) +} + +// callAdminHandler calls admin handlers directly without socket +func (w *WebUIServer) callAdminHandler(command string, args map[string]interface{}) (interface{}, error) { + argsBytes, err := json.Marshal(args) + if err != nil { + argsBytes = []byte("{}") + } + + return w.admin.CallHandler(command, argsBytes) +} + func (w *WebUIServer) Start() error { // Validate listen address before starting if w.listen != "" { @@ -330,6 +399,9 @@ func (w *WebUIServer) Start() error { mux.HandleFunc("/auth/login", w.loginHandler) mux.HandleFunc("/auth/logout", w.logoutHandler) + // Admin API endpoints - with auth + mux.HandleFunc("/api/admin/", w.authMiddleware(w.adminAPIHandler)) + // Setup static files handler (implementation varies by build) setupStaticHandler(mux, w) diff --git a/src/webui/static/api.js b/src/webui/static/api.js new file mode 100644 index 00000000..9ba45008 --- /dev/null +++ b/src/webui/static/api.js @@ -0,0 +1,181 @@ +/** + * Yggdrasil Admin API Client + * Provides JavaScript interface for accessing Yggdrasil admin functions + */ +class YggdrasilAPI { + constructor() { + this.baseURL = '/api/admin'; + } + + /** + * Generic method to call admin API endpoints + * @param {string} command - Admin command name + * @param {Object} args - Command arguments + * @returns {Promise} - API response + */ + async callAdmin(command, args = {}) { + const url = command ? `${this.baseURL}/${command}` : this.baseURL; + const options = { + method: Object.keys(args).length > 0 ? 'POST' : 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin' // Include session cookies + }; + + if (Object.keys(args).length > 0) { + options.body = JSON.stringify(args); + } + + try { + const response = await fetch(url, options); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.status === 'error') { + throw new Error(data.error || 'Unknown API error'); + } + + return data.response || data.commands; + } catch (error) { + console.error(`API call failed for ${command}:`, error); + throw error; + } + } + + /** + * Get list of available admin commands + * @returns {Promise} - List of available commands + */ + async getCommands() { + return await this.callAdmin(''); + } + + /** + * Get information about this node + * @returns {Promise} - Node information + */ + async getSelf() { + return await this.callAdmin('getSelf'); + } + + /** + * Get list of connected peers + * @returns {Promise} - Peers information + */ + async getPeers() { + return await this.callAdmin('getPeers'); + } + + /** + * Get tree routing information + * @returns {Promise} - Tree information + */ + async getTree() { + return await this.callAdmin('getTree'); + } + + /** + * Get established paths through this node + * @returns {Promise} - Paths information + */ + async getPaths() { + return await this.callAdmin('getPaths'); + } + + /** + * Get established traffic sessions with remote nodes + * @returns {Promise} - Sessions information + */ + async getSessions() { + return await this.callAdmin('getSessions'); + } + + /** + * Add a peer to the peer list + * @param {string} uri - Peer URI (e.g., "tls://example.com:12345") + * @param {string} int - Network interface (optional) + * @returns {Promise} - Add peer response + */ + async addPeer(uri, int = '') { + return await this.callAdmin('addPeer', { uri, int }); + } + + /** + * Remove a peer from the peer list + * @param {string} uri - Peer URI to remove + * @param {string} int - Network interface (optional) + * @returns {Promise} - Remove peer response + */ + async removePeer(uri, int = '') { + return await this.callAdmin('removePeer', { uri, int }); + } +} + +/** + * Data formatting utilities + */ +class YggdrasilUtils { + /** + * Format bytes to human readable format + * @param {number} bytes - Bytes count + * @returns {string} - Formatted string (e.g., "1.5 MB") + */ + static formatBytes(bytes) { + if (bytes === 0) return '0 B'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + /** + * Format duration to human readable format + * @param {number} seconds - Duration in seconds + * @returns {string} - Formatted duration + */ + static formatDuration(seconds) { + if (seconds < 60) return `${Math.round(seconds)}s`; + if (seconds < 3600) return `${Math.round(seconds / 60)}m`; + if (seconds < 86400) return `${Math.round(seconds / 3600)}h`; + return `${Math.round(seconds / 86400)}d`; + } + + /** + * Format public key for display (show first 8 and last 8 chars) + * @param {string} key - Full public key + * @returns {string} - Shortened key + */ + static formatPublicKey(key) { + if (!key || key.length < 16) return key; + return `${key.substring(0, 8)}...${key.substring(key.length - 8)}`; + } + + /** + * Get status color class based on peer state + * @param {boolean} up - Whether peer is up + * @returns {string} - CSS class name + */ + static getPeerStatusClass(up) { + return up ? 'status-online' : 'status-offline'; + } + + /** + * Get status text based on peer state + * @param {boolean} up - Whether peer is up + * @returns {string} - Status text + */ + static getPeerStatusText(up) { + return up ? 'Online' : 'Offline'; + } +} + +// Create global API instance +window.yggAPI = new YggdrasilAPI(); +window.yggUtils = YggdrasilUtils; \ No newline at end of file diff --git a/src/webui/static/app.js b/src/webui/static/app.js new file mode 100644 index 00000000..0444adcf --- /dev/null +++ b/src/webui/static/app.js @@ -0,0 +1,275 @@ +/** + * Yggdrasil WebUI Application Logic + * Integrates admin API with the user interface + */ + +// Global state +let nodeInfo = null; +let peersData = null; +let isLoading = false; + +/** + * Load and display node information + */ +async function loadNodeInfo() { + try { + const info = await window.yggAPI.getSelf(); + nodeInfo = info; + updateNodeInfoDisplay(info); + return info; + } catch (error) { + console.error('Failed to load node info:', error); + showError('Failed to load node information: ' + error.message); + throw error; + } +} + +/** + * Load and display peers information + */ +async function loadPeers() { + try { + const data = await window.yggAPI.getPeers(); + peersData = data; + updatePeersDisplay(data); + return data; + } catch (error) { + console.error('Failed to load peers:', error); + showError('Failed to load peers information: ' + error.message); + throw error; + } +} + +/** + * Update node information in the UI + */ +function updateNodeInfoDisplay(info) { + // Update status section elements + updateElementText('node-address', info.address || 'N/A'); + updateElementText('node-subnet', info.subnet || 'N/A'); + updateElementText('node-key', yggUtils.formatPublicKey(info.key) || 'N/A'); + updateElementText('node-version', `${info.build_name} ${info.build_version}` || 'N/A'); + updateElementText('routing-entries', info.routing_entries || '0'); + + // Update full key display (for copy functionality) + updateElementData('node-key-full', info.key || ''); +} + +/** + * Update peers information in the UI + */ +function updatePeersDisplay(data) { + const peersContainer = document.getElementById('peers-list'); + if (!peersContainer) return; + + peersContainer.innerHTML = ''; + + if (!data.peers || data.peers.length === 0) { + peersContainer.innerHTML = '
No peers connected
'; + return; + } + + data.peers.forEach(peer => { + const peerElement = createPeerElement(peer); + peersContainer.appendChild(peerElement); + }); + + // Update peer count + updateElementText('peers-count', data.peers.length.toString()); + updateElementText('peers-online', data.peers.filter(p => p.up).length.toString()); +} + +/** + * Create HTML element for a single peer + */ +function createPeerElement(peer) { + const div = document.createElement('div'); + div.className = 'peer-item'; + + const statusClass = yggUtils.getPeerStatusClass(peer.up); + const statusText = yggUtils.getPeerStatusText(peer.up); + + div.innerHTML = ` +
+
${peer.address || 'N/A'}
+
${statusText}
+
+
+
${peer.remote || 'N/A'}
+
+ ↓ ${yggUtils.formatBytes(peer.bytes_recvd || 0)} + ↑ ${yggUtils.formatBytes(peer.bytes_sent || 0)} + ${peer.up && peer.latency ? `RTT: ${(peer.latency / 1000000).toFixed(1)}ms` : ''} +
+
+ ${peer.remote ? `` : ''} + `; + + return div; +} + +/** + * Add a new peer + */ +async function addPeer() { + const uri = prompt('Enter peer URI:\nExamples:\n• tcp://example.com:54321\n• tls://peer.yggdrasil.network:443'); + if (!uri || uri.trim() === '') { + showWarning('Peer URI is required'); + return; + } + + // Basic URI validation + if (!uri.includes('://')) { + showError('Invalid URI format. Must include protocol (tcp://, tls://, etc.)'); + return; + } + + try { + showInfo('Adding peer...'); + await window.yggAPI.addPeer(uri.trim()); + showSuccess(`Peer added successfully: ${uri.trim()}`); + await loadPeers(); // Refresh peer list + } catch (error) { + showError('Failed to add peer: ' + error.message); + } +} + +/** + * Remove peer with confirmation + */ +function removePeerConfirm(uri) { + if (confirm(`Remove peer?\n${uri}`)) { + removePeer(uri); + } +} + +/** + * Remove a peer + */ +async function removePeer(uri) { + try { + showInfo('Removing peer...'); + await window.yggAPI.removePeer(uri); + showSuccess(`Peer removed successfully: ${uri}`); + await loadPeers(); // Refresh peer list + } catch (error) { + showError('Failed to remove peer: ' + error.message); + } +} + +/** + * Helper function to update element text content + */ +function updateElementText(id, text) { + const element = document.getElementById(id); + if (element) { + element.textContent = text; + } +} + +/** + * Helper function to update element data attribute + */ +function updateElementData(id, data) { + const element = document.getElementById(id); + if (element) { + element.setAttribute('data-value', data); + } +} + +/** + * Copy text to clipboard + */ +async function copyToClipboard(text) { + try { + await navigator.clipboard.writeText(text); + showSuccess('Copied to clipboard'); + } catch (error) { + console.error('Failed to copy:', error); + showError('Failed to copy to clipboard'); + } +} + +/** + * Copy node key to clipboard + */ +function copyNodeKey() { + const element = document.getElementById('node-key-full'); + if (element) { + const key = element.getAttribute('data-value'); + if (key) { + copyToClipboard(key); + } + } +} + +/** + * Auto-refresh data + */ +function startAutoRefresh() { + // Refresh every 30 seconds + setInterval(async () => { + if (!isLoading) { + try { + await Promise.all([loadNodeInfo(), loadPeers()]); + } catch (error) { + console.error('Auto-refresh failed:', error); + } + } + }, 30000); +} + +/** + * Initialize application + */ +async function initializeApp() { + try { + // Ensure API is available + if (typeof window.yggAPI === 'undefined') { + console.error('yggAPI is not available'); + showError('Failed to initialize API client'); + return; + } + + isLoading = true; + showInfo('Loading dashboard...'); + + // Load initial data + await Promise.all([loadNodeInfo(), loadPeers()]); + + showSuccess('Dashboard loaded successfully'); + + // Start auto-refresh + startAutoRefresh(); + + } catch (error) { + showError('Failed to initialize dashboard: ' + error.message); + } finally { + isLoading = false; + } +} + +// Wait for DOM and API to be ready +function waitForAPI() { + console.log('Checking for yggAPI...', typeof window.yggAPI); + + if (typeof window.yggAPI !== 'undefined') { + console.log('yggAPI found, initializing app...'); + initializeApp(); + } else { + console.log('yggAPI not ready yet, retrying...'); + // Retry after a short delay + setTimeout(waitForAPI, 100); + } +} + +// Initialize when DOM is ready +console.log('App.js loaded, document ready state:', document.readyState); + +if (document.readyState === 'loading') { + console.log('Document still loading, waiting for DOMContentLoaded...'); + document.addEventListener('DOMContentLoaded', waitForAPI); +} else { + console.log('Document already loaded, starting API check...'); + initializeApp(); +} \ No newline at end of file diff --git a/src/webui/static/index.html b/src/webui/static/index.html index 5a8cf906..9e41333c 100644 --- a/src/webui/static/index.html +++ b/src/webui/static/index.html @@ -6,8 +6,10 @@ Yggdrasil Web Interface + + - + @@ -56,24 +58,36 @@

Состояние узла

-
- - Активен -
-

WebUI запущен и доступен

+

Информация о текущем состоянии вашего узла Yggdrasil

-

Сетевая информация

-

Адрес: 200:1234:5678:9abc::1

-

Подсеть: 300:1234:5678:9abc::/64

+

Информация об узле

+

Публичный ключ: Загрузка...

+

Версия: Загрузка...

+

Записей маршрутизации: Загрузка...

+
-

Статистика

-

Время работы: 2д 15ч 42м

-

Активных соединений: 3

+

Сетевая информация

+

Адрес: Загрузка...

+

Подсеть: Загрузка...

+
+ +
+

Статистика пиров

+

Всего пиров: Загрузка...

+

Онлайн пиров: Загрузка...

@@ -85,16 +99,17 @@
-
-

Активные пиры

-

Количество активных соединений

- Функция в разработке... -
-

Добавить пир

Подключение к новому узлу

- Функция в разработке... + +
+
+ +
+

Подключенные пиры

+
+
Загрузка...
@@ -127,6 +142,9 @@ + +
+