diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-09-28 11:16:46 +0300 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-09-28 11:16:46 +0300 |
| commit | 47529804bef15ed84730ff3409f0d426fcef2112 (patch) | |
| tree | 9f2d786eb5082370025307c07c06354a72e2e007 | |
| parent | 650138aae6a2d6ae26f57a22056bdf0ea5fa8c77 (diff) | |
| download | network-47529804bef15ed84730ff3409f0d426fcef2112.tar.zst | |
Iteration
| -rw-r--r-- | flake.nix | 70 | ||||
| -rw-r--r-- | index.html | 391 | ||||
| -rw-r--r-- | main.go | 275 | ||||
| -rw-r--r-- | requirements.json | 43 |
4 files changed, 694 insertions, 85 deletions
diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..9a8ec32 --- /dev/null +++ b/flake.nix @@ -0,0 +1,70 @@ +{ + description = "systemd-networkd Web UI (Go + HTML/JS)"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + # Kehitysympäristö + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + go_1_24 + gopls # LSP-tuki + ]; + + shellHook = '' + echo "Development shell for systemd-networkd Web UI" + echo "Go version: $(go version)" + ''; + }; + + # Sovelluksen buildattava paketti + packages.default = pkgs.buildGoModule { + pname = "networkd-webui"; + version = "0.1.0"; + + src = ./.; + + vendorHash = null; # täytetään `nix develop` -> `go mod vendor` -> `nix hash` + subPackages = [ "." ]; + + ldflags = [ "-s" "-w" ]; + }; + + # Deploymenttiin tarkoitettu NixOS service module + nixosModules.default = { config, pkgs, lib, ... }: { + options.services.networkd-webui = { + enable = lib.mkEnableOption "systemd-networkd Web UI"; + port = lib.mkOption { + type = lib.types.port; + default = 8080; + description = "Port for the web UI."; + }; + }; + + config = lib.mkIf config.services.networkd-webui.enable { + systemd.services.networkd-webui = { + description = "systemd-networkd Web UI"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + ExecStart = "${self.packages.${system}.default}/bin/networkd-webui -port ${toString config.services.networkd-webui.port}"; + Restart = "on-failure"; + DynamicUser = true; + ProtectSystem = "strict"; + ProtectHome = true; + NoNewPrivileges = true; + }; + }; + }; + }; + }); +} @@ -1,80 +1,377 @@ -<!DOCTYPE html> +<!-- index.html --> +<!doctype html> <html lang="en"> <head> - <meta charset="UTF-8"> - <title>Systemd-networkd Control Panel</title> + <meta charset="utf-8" /> + <title>Network UI — Control Panel</title> + <meta name="viewport" content="width=device-width,initial-scale=1" /> <script type="module" src="https://unpkg.com/@fluentui/web-components@2.6.1/dist/web-components.min.js"></script> <style> + :root { + --panel-gap: 18px; + } + body { font-family: Arial, sans-serif; - margin: 2em; + margin: 0; + } + + header { + background: #f3f3f3; + padding: 12px 20px; + border-bottom: 1px solid #ddd; + } + + main { + display: flex; + height: calc(100vh - 58px); + } + + nav { + width: 220px; + border-right: 1px solid #eee; + padding: 16px; + box-sizing: border-box; } section { - margin-bottom: 2em; + flex: 1; + padding: 16px; + overflow: auto; + box-sizing: border-box; + } + + .panel { + background: #fff; + border: 1px solid #eee; + padding: 12px; + border-radius: 8px; + margin-bottom: 12px; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02); + } + + .iface { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + .small { + font-size: 0.9em; + color: #444; } pre { - background: #f5f5f5; - padding: 1em; + background: #fafafa; + padding: 8px; border-radius: 6px; - overflow-x: auto; + overflow: auto; + } + + textarea { + width: 100%; + height: 360px; + font-family: monospace; + font-size: 13px; + } + + label { + display: block; + margin-bottom: 6px; + font-weight: 600; + } + + .controls { + display: flex; + gap: 10px; + align-items: center; } </style> </head> <body> + <header> + <h2 style="margin:0">Systemd-networkd — Admin Panel</h2> + <div class="small">Single page admin + teaching panel. Auth: HTTP Basic.</div> + </header> + + <main> + <nav> + <fluent-button appearance="secondary" id="btnStatus" style="width:100%; margin-bottom:8px" + onclick="show('status')">Network Status</fluent-button> + <fluent-button appearance="secondary" id="btnConfigs" style="width:100%; margin-bottom:8px" + onclick="show('configs')">Configs</fluent-button> + <fluent-button appearance="secondary" id="btnLogs" style="width:100%; margin-bottom:8px" + onclick="show('logs')">Logs</fluent-button> + <fluent-button appearance="accent" id="btnCommands" style="width:100%; margin-top:14px" + onclick="show('commands')">Commands</fluent-button> + <div style="margin-top:18px; font-size:0.9em; color:#555"> + <strong>Tips</strong> + <ul> + <li>Validate before saving (systemd-analyze verify).</li> + <li>Backups are created automatically on save.</li> + </ul> + </div> + </nav> + + <section id="panelStatus" style="display:block;"> + <div class="panel"> + <div style="display:flex; justify-content:space-between; align-items:center;"> + <div> + <h3 style="margin:0">Network Status</h3> + <div class="small">Parsed output from <code>networkctl status --json=short</code></div> + </div> + <div><fluent-button appearance="accent" onclick="loadStatus()">Refresh</fluent-button></div> + </div> + </div> + <div id="ifaces"></div> + </section> + + <section id="panelConfigs" style="display:none;"> + <div class="panel"> + <div style="display:flex; justify-content:space-between; align-items:center;"> + <div> + <h3 style="margin:0">Configurations</h3> + <div class="small">Edit <code>/etc/systemd/network/*.network</code> files. Validate -> Save -> + (optional) Restart</div> + </div> + <div class="controls"> + <fluent-button onclick="refreshConfigs()">Refresh list</fluent-button> + <fluent-button appearance="accent" onclick="saveConfig()">Save</fluent-button> + </div> + </div> + </div> + + <div class="panel"> + <label for="configSelect">Config file</label> + <select id="configSelect" style="width:100%; padding:8px; margin-bottom:8px" + onchange="loadConfig()"></select> + <label for="cfgEditor">Contents</label> + <textarea id="cfgEditor" spellcheck="false"></textarea> + <div style="margin-top:8px;"> + <input type="checkbox" id="restartAfterSave" /> <label for="restartAfterSave" + style="display:inline; font-weight:normal">Restart systemd-networkd after save</label> + <fluent-button style="margin-left:8px" onclick="validateConfig()">Validate</fluent-button> + <span id="validateResult" class="small" style="margin-left:12px"></span> + </div> + </div> + </section> - <h1>Systemd-networkd Control Panel</h1> - <p>This page shows network status, logs, and allows you to restart network services or reboot the device. - For more details see <a href="https://www.freedesktop.org/software/systemd/man/systemd-networkd.service.html" - target="_blank">systemd-networkd documentation</a>.</p> - - <section> - <h2>1. Network Status</h2> - <p>The command <code>networkctl status --json=short</code> shows current interfaces, addresses, and routes.</p> - <fluent-button appearance="accent" onclick="loadStatus()">Refresh Status</fluent-button> - <pre id="status">[Waiting for data]</pre> - </section> - - <section> - <h2>2. Network Logs</h2> - <p>Logs come from <code>journalctl -u systemd-networkd.service</code>. This helps diagnose DHCP and link issues. - </p> - <fluent-button onclick="loadLogs()">Show Logs</fluent-button> - <pre id="logs">[Waiting for logs]</pre> - </section> - - <section> - <h2>3. Manage Services</h2> - <p> - - Reload networkd after configuration changes.<br> - - Reboot device if required. - </p> - <fluent-button appearance="accent" onclick="reloadNetworkd()">Restart networkd</fluent-button> - <fluent-button appearance="accent" onclick="rebootDevice()">Reboot device</fluent-button> - </section> + <section id="panelLogs" style="display:none;"> + <div class="panel"> + <div style="display:flex; justify-content:space-between;"> + <div> + <h3 style="margin:0">systemd-networkd Logs</h3> + <div class="small">journalctl -u systemd-networkd.service</div> + </div> + <fluent-button appearance="accent" onclick="loadLogs()">Refresh</fluent-button> + </div> + </div> + <div class="panel"> + <pre id="logsArea">[no logs]</pre> + </div> + </section> + + <section id="panelCommands" style="display:none;"> + <div class="panel"> + <h3 style="margin-top:0">Commands</h3> + <div class="small" style="margin-bottom:8px">Restart networkd or reboot device.</div> + <div class="controls"> + <fluent-button appearance="accent" onclick="restartNetworkd()">Restart networkd</fluent-button> + <fluent-button appearance="warning" onclick="rebootDevice()">Reboot device</fluent-button> + </div> + <div id="cmdResult" style="margin-top:12px"></div> + </div> + </section> + </main> <script> + function show(panel) { + document.getElementById('panelStatus').style.display = panel === 'status' ? 'block' : 'none'; + document.getElementById('panelConfigs').style.display = panel === 'configs' ? 'block' : 'none'; + document.getElementById('panelLogs').style.display = panel === 'logs' ? 'block' : 'none'; + document.getElementById('panelCommands').style.display = panel === 'commands' ? 'block' : 'none'; + if (panel === 'status') loadStatus(); + if (panel === 'configs') refreshConfigs(); + if (panel === 'logs') loadLogs(); + } + + async function api(path, opts) { + const res = await fetch(path, opts); + if (!res.ok) { + const text = await res.text(); + throw new Error(res.status + ' ' + text); + } + return res; + } + async function loadStatus() { - let res = await fetch('/api/status'); - document.getElementById('status').textContent = await res.text(); + try { + const res = await api('/api/status'); + const data = await res.json(); + renderIfaces(data.Interfaces || []); + } catch (e) { + document.getElementById('ifaces').innerHTML = '<div class="panel">Error loading status: ' + e.message + '</div>'; + } + } + + function renderIfaces(ifaces) { + const out = []; + if (ifaces.length === 0) { + out.push('<div class="panel">No interfaces</div>'); + } + for (const ifc of ifaces) { + const name = ifc.Name || 'n/a'; + const typ = ifc.Type || ''; + const state = ifc.OperationalState || ifc.AdministrativeState || ''; + const mac = ifc.HardwareAddress ? arrayToMac(ifc.HardwareAddress) : ''; + const addrs = (ifc.Addresses || []).map(a => ipFromArray(a)).join('<br>'); + const routes = (ifc.Routes || []).map(r => routeToString(r)).join('<br>'); + const dns = (ifc.DNS || []).map(d => ipFromArray(d.Address || d)).join('<br>'); + out.push(`<div class="panel"><h4 style="margin:0">${name} <small class="small">${typ}</small></h4> + <div class="iface"> + <div><strong>State</strong><div class="small">${state}</div></div> + <div><strong>MAC</strong><div class="small">${mac}</div></div> + <div><strong>Addresses</strong><div class="small">${addrs}</div></div> + <div><strong>DNS</strong><div class="small">${dns}</div></div> + <div style="grid-column:1/-1"><strong>Routes</strong><div class="small">${routes}</div></div> + </div></div>`); + } + document.getElementById('ifaces').innerHTML = out.join(''); + } + + function arrayToMac(a) { + if (!Array.isArray(a)) return ''; + return a.map(x => ('0' + x.toString(16)).slice(-2)).join(':'); + } + function ipFromArray(obj) { + // obj can be { Family: number, Address: [..] } or {Address: [...]} + let arr = null; + if (Array.isArray(obj)) arr = obj; + else if (obj && Array.isArray(obj.Address)) arr = obj.Address; + else return ''; + // detect IPv4 vs IPv6 + if (arr.length === 4) return arr.join('.'); + if (arr.length === 16) { + // IPv6 - format groups of 2 bytes + const parts = []; + for (let i = 0; i < 16; i += 2) { + parts.push(((arr[i] << 8) | arr[i + 1]).toString(16)); + } + return parts.join(':').replace(/(:0+)+/, '::'); + } + return JSON.stringify(arr); + } + function routeToString(r) { + if (!r) return ''; + const dest = r.Destination ? ipFromArray(r.Destination) : ''; + const pref = r.Gateway ? ipFromArray(r.Gateway) : ''; + return dest + (pref ? ' → ' + pref : ''); + } + + /* Configs editor */ + async function refreshConfigs() { + try { + const res = await api('/api/configs'); + const data = await res.json(); + const sel = document.getElementById('configSelect'); + sel.innerHTML = ''; + data.files.forEach(f => { + const opt = document.createElement('option'); + opt.value = f; opt.textContent = f; + sel.appendChild(opt); + }); + if (data.files.length > 0) { + loadConfig(); + } else { + document.getElementById('cfgEditor').value = ''; + } + } catch (e) { + alert('Failed to list configs: ' + e.message); + } + } + + async function loadConfig() { + const name = document.getElementById('configSelect').value; + if (!name) return; + try { + const res = await api('/api/config/' + encodeURIComponent(name)); + const txt = await res.text(); + document.getElementById('cfgEditor').value = txt; + document.getElementById('validateResult').textContent = ''; + } catch (e) { + alert('Failed to load: ' + e.message); + } + } + + async function validateConfig() { + const name = document.getElementById('configSelect').value; + const content = document.getElementById('cfgEditor').value; + document.getElementById('validateResult').textContent = 'Validating...'; + try { + const res = await api('/api/validate', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, content})}); + const r = await res.json(); + if (r.ok) { + document.getElementById('validateResult').textContent = 'OK'; + } else { + document.getElementById('validateResult').textContent = 'Error: ' + (r.output || 'validation failed'); + } + } catch (e) { + document.getElementById('validateResult').textContent = 'Error: ' + e.message; + } + } + + async function saveConfig() { + const name = document.getElementById('configSelect').value; + const content = document.getElementById('cfgEditor').value; + const restart = document.getElementById('restartAfterSave').checked; + if (!confirm('Save file ' + name + '? This will create a backup and (optionally) restart networkd.')) return; + try { + const res = await api('/api/save', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, content, restart})}); + const r = await res.json(); + alert('Saved: ' + (r.status || 'ok')); + } catch (e) { + alert('Save failed: ' + e.message); + } } + + /* Logs & commands */ async function loadLogs() { - let res = await fetch('/api/logs'); - document.getElementById('logs').textContent = await res.text(); + try { + const res = await api('/api/logs'); + const txt = await res.text(); + document.getElementById('logsArea').textContent = txt; + } catch (e) { + document.getElementById('logsArea').textContent = 'Error: ' + e.message; + } } - async function reloadNetworkd() { - let res = await fetch('/api/reload', {method: 'POST'}); - alert(await res.text()); + + async function restartNetworkd() { + if (!confirm('Restart systemd-networkd? Active connections may be reset.')) return; + try { + const res = await api('/api/reload', {method: 'POST'}); + const j = await res.json(); + document.getElementById('cmdResult').textContent = JSON.stringify(j); + } catch (e) { + document.getElementById('cmdResult').textContent = 'Error: ' + e.message; + } } + async function rebootDevice() { - let res = await fetch('/api/reboot', {method: 'POST'}); - alert(await res.text()); + if (!confirm('Reboot device now?')) return; + try { + const res = await api('/api/reboot', {method: 'POST'}); + const j = await res.json(); + document.getElementById('cmdResult').textContent = JSON.stringify(j); + } catch (e) { + document.getElementById('cmdResult').textContent = 'Error: ' + e.message; + } } - </script> + /* initial */ + loadStatus(); + </script> </body> </html> @@ -1,72 +1,301 @@ +// main.go package main import ( + "encoding/base64" "encoding/json" "html/template" + "io" "log" "net/http" + "os" "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + configDir = "/etc/systemd/network" + listenAddress = ":8080" ) -// Templates var tpl = template.Must(template.ParseFiles("index.html")) -// Handlers +// basicAuth wraps any http.Handler and enforces HTTP Basic Authentication. +func basicAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + const realm = "Restricted" + + auth := r.Header.Get("Authorization") + if auth == "" { + w.Header().Set("WWW-Authenticate", `Basic realm="`+realm+`"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + const prefix = "Basic " + if !strings.HasPrefix(auth, prefix) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + payload, err := base64.StdEncoding.DecodeString(auth[len(prefix):]) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + pair := strings.SplitN(string(payload), ":", 2) + if len(pair) != 2 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + username := os.Getenv("WEBUI_USER") + password := os.Getenv("WEBUI_PASS") + + if pair[0] != username || pair[1] != password { + w.Header().Set("WWW-Authenticate", `Basic realm="`+realm+`"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // authentication OK -> pass to handler + next.ServeHTTP(w, r) + }) +} + func indexHandler(w http.ResponseWriter, r *http.Request) { tpl.Execute(w, nil) } -// Run `networkctl status --json=short` +// status: runs networkctl status --json=short and returns parsed JSON func statusHandler(w http.ResponseWriter, r *http.Request) { cmd := exec.Command("networkctl", "status", "--json=short") out, err := cmd.Output() if err != nil { - http.Error(w, err.Error(), 500) + http.Error(w, "failed to run networkctl: "+err.Error(), http.StatusInternalServerError) return } + // return raw JSON (already valid) w.Header().Set("Content-Type", "application/json") w.Write(out) } -// Run `journalctl -u systemd-networkd.service --no-pager -n 200` +// logs: journalctl -u systemd-networkd.service --no-pager -n 500 func logsHandler(w http.ResponseWriter, r *http.Request) { - cmd := exec.Command("journalctl", "-u", "systemd-networkd.service", "--no-pager", "-n", "200") + cmd := exec.Command("journalctl", "-u", "systemd-networkd.service", "--no-pager", "-n", "500") out, err := cmd.Output() if err != nil { - http.Error(w, err.Error(), 500) + http.Error(w, "failed to run journalctl: "+err.Error(), http.StatusInternalServerError) return } - w.Header().Set("Content-Type", "text/plain") + w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write(out) } -// Reload systemd-networkd -func reloadHandler(w http.ResponseWriter, r *http.Request) { - cmd := exec.Command("systemctl", "restart", "systemd-networkd") - if err := cmd.Run(); err != nil { +// list config files in /etc/systemd/network +func listConfigsHandler(w http.ResponseWriter, r *http.Request) { + files := []string{} + matches, err := filepath.Glob(filepath.Join(configDir, "*")) + if err != nil { http.Error(w, err.Error(), 500) return } - json.NewEncoder(w).Encode(map[string]string{"status": "restarted"}) + for _, p := range matches { + // only regular files + if fi, err := os.Stat(p); err == nil && fi.Mode().IsRegular() { + files = append(files, filepath.Base(p)) + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"files": files}) } -// Reboot the system +// get a specific config file content +func getConfigHandler(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(r.URL.Path, "/api/config/") + if name == "" { + http.Error(w, "missing config name", 400) + return + } + // sanitize + if strings.Contains(name, "..") || strings.ContainsRune(name, os.PathSeparator) { + http.Error(w, "invalid name", 400) + return + } + path := filepath.Join(configDir, name) + data, err := os.ReadFile(path) + if err != nil { + http.Error(w, "read error: "+err.Error(), 500) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write(data) +} + +// validate a config content using systemd-analyze verify (writes to tmp file) +func validateConfigHandler(w http.ResponseWriter, r *http.Request) { + // expects JSON: { "name": "10-lan.network", "content": "..." } + var req struct { + Name string `json:"name"` + Content string `json:"content"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), 400) + return + } + if req.Name == "" { + http.Error(w, "missing name", 400) + return + } + tmpFile, err := os.CreateTemp("", "network-verify-*.network") + if err != nil { + http.Error(w, "tmpfile: "+err.Error(), 500) + return + } + defer os.Remove(tmpFile.Name()) + if _, err := tmpFile.WriteString(req.Content); err != nil { + http.Error(w, "write tmp: "+err.Error(), 500) + return + } + tmpFile.Close() + cmd := exec.Command("systemd-analyze", "verify", tmpFile.Name()) + out, err := cmd.CombinedOutput() + resp := map[string]interface{}{} + if err != nil { + resp["ok"] = false + resp["output"] = string(out) + } else { + resp["ok"] = true + resp["output"] = "OK" + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// save config: validate, backup original, atomically write, optionally restart networkd if query param restart=true +func saveConfigHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Content string `json:"content"` + Restart bool `json:"restart"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), 400) + return + } + if req.Name == "" { + http.Error(w, "missing name", 400) + return + } + if strings.Contains(req.Name, "..") || strings.ContainsRune(req.Name, os.PathSeparator) { + http.Error(w, "invalid name", 400) + return + } + dst := filepath.Join(configDir, req.Name) + // 1) validate by writing to tmp and running systemd-analyze verify + tmpFile, err := os.CreateTemp("", "network-save-*.network") + if err != nil { + http.Error(w, "tmpfile: "+err.Error(), 500) + return + } + tmpPath := tmpFile.Name() + if _, err := tmpFile.WriteString(req.Content); err != nil { + http.Error(w, "write tmp: "+err.Error(), 500) + tmpFile.Close() + os.Remove(tmpPath) + return + } + tmpFile.Close() + cmd := exec.Command("systemd-analyze", "verify", tmpPath) + if out, err := cmd.CombinedOutput(); err != nil { + os.Remove(tmpPath) + http.Error(w, "validation failed:\n"+string(out), 400) + return + } + // 2) create backup of existing file (if exists) + if _, err := os.Stat(dst); err == nil { + ts := strconv.FormatInt(time.Now().Unix(), 10) + backup := dst + ".bak." + ts + if err := copyFile(dst, backup); err != nil { + os.Remove(tmpPath) + http.Error(w, "failed to backup: "+err.Error(), 500) + return + } + } + // 3) atomic move tmp -> dst (use os.Rename) + if err := os.Rename(tmpPath, dst); err != nil { + os.Remove(tmpPath) + http.Error(w, "rename failed: "+err.Error(), 500) + return + } + // 4) optionally restart networkd + if req.Restart { + if err := exec.Command("systemctl", "restart", "systemd-networkd").Run(); err != nil { + http.Error(w, "saved but restart failed: "+err.Error(), 500) + return + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "saved"}) +} + +// restart networkd +func reloadHandler(w http.ResponseWriter, r *http.Request) { + if err := exec.Command("systemctl", "restart", "systemd-networkd").Run(); err != nil { + http.Error(w, "restart failed: "+err.Error(), 500) + return + } + json.NewEncoder(w).Encode(map[string]string{"status": "networkd restarted"}) +} + +// reboot system func rebootHandler(w http.ResponseWriter, r *http.Request) { + // start reboot asynchronously cmd := exec.Command("systemctl", "reboot") if err := cmd.Start(); err != nil { - http.Error(w, err.Error(), 500) + http.Error(w, "reboot failed: "+err.Error(), 500) return } json.NewEncoder(w).Encode(map[string]string{"status": "rebooting"}) } +// helper copy +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Sync() +} + func main() { - http.HandleFunc("/", indexHandler) - http.HandleFunc("/api/status", statusHandler) - http.HandleFunc("/api/logs", logsHandler) - http.HandleFunc("/api/reload", reloadHandler) - http.HandleFunc("/api/reboot", rebootHandler) - - log.Println("Listening on :8080") - log.Fatal(http.ListenAndServe(":8080", nil)) + http.Handle("/", basicAuth(http.HandlerFunc(indexHandler))) + http.Handle("/api/status", basicAuth(http.HandlerFunc(statusHandler))) + http.Handle("/api/logs", basicAuth(http.HandlerFunc(logsHandler))) + http.Handle("/api/configs", basicAuth(http.HandlerFunc(listConfigsHandler))) + http.Handle("/api/config/", basicAuth(http.HandlerFunc(getConfigHandler))) // GET /api/config/<name> + http.Handle("/api/validate", basicAuth(http.HandlerFunc(validateConfigHandler))) // POST + http.Handle("/api/save", basicAuth(http.HandlerFunc(saveConfigHandler))) // POST + http.Handle("/api/reload", basicAuth(http.HandlerFunc(reloadHandler))) // POST + http.Handle("/api/reboot", basicAuth(http.HandlerFunc(rebootHandler))) // POST + + // serve static files (none for now) - allow index to reference local assets if needed + fs := http.FileServer(http.Dir("./static")) + http.Handle("/static/", basicAuth(http.StripPrefix("/static/", fs))) + + log.Println("Starting network-ui on", listenAddress) + log.Fatal(http.ListenAndServe(listenAddress, nil)) } diff --git a/requirements.json b/requirements.json index 3e0b728..6dd5f58 100644 --- a/requirements.json +++ b/requirements.json @@ -1,61 +1,74 @@ { - "project": "systemd-networkd Web UI", - "version": "0.1", + "project": "systemd-networkd simple Web UI", + "version": "0.2", + "goal": "Make a easy to use interface to configure router to teach network basics for high schoolers purposes", "requirements": { "functional": [ { "id": "F-001", - "description": "The system shall provide a web-based dashboard to display current network status (interfaces, addresses, routes) by invoking `networkctl status --json=short`." + "description": "The system shall provide a structured web-based control panel with application-style layout (navigation menu, panels, command buttons)." }, { "id": "F-002", - "description": "The system shall provide access to logs of `systemd-networkd.service` via `journalctl`." + "description": "The system shall parse `networkctl status --json=short` output and present each interface as a structured panel showing: name, MAC, state, addresses, routes, DNS." }, { "id": "F-003", - "description": "The system shall provide controls to restart `systemd-networkd` without rebooting the device." + "description": "The system shall allow browsing of existing network configuration files under `/etc/systemd/network/`." }, { "id": "F-004", - "description": "The system shall provide a control to reboot the device." + "description": "The system shall display each configuration file (`*.network`, `*.netdev`, `*.link`) as editable text with syntax highlighting and validation hooks." }, { "id": "F-005", - "description": "The system shall present contextual teaching information (e.g., IPv4 basics, logs explanation, restart rationale) directly within the web interface." + "description": "The system shall validate edited configuration against systemd-networkd rules before allowing save (e.g., reject unknown keys, missing sections)." }, { "id": "F-006", - "description": "The system shall use a single-page design with minimal dependencies (HTML + Fluent UI web components from CDN)." + "description": "The system shall provide versioned backups of configuration files, allowing rollback to earlier revisions." + }, + { + "id": "F-007", + "description": "The system shall provide controls to apply changes safely by restarting only `systemd-networkd` (not full reboot)." + }, + { + "id": "F-008", + "description": "The system shall provide a full device reboot option for recovery." } ], "technical": [ { "id": "T-001", - "description": "The backend shall be implemented in Go, exposing HTTP endpoints on port 80." + "description": "The backend shall be implemented in Go, exposing HTTP endpoints for structured data (JSON) and file operations." }, { "id": "T-002", - "description": "The system shall run as a `systemd` service with restricted privileges (User=network-ui, ProtectSystem=strict, NoNewPrivileges=yes)." + "description": "The backend shall include a parser for systemd-networkd configuration files, mapping sections ([Match], [Link], [Network], etc.) into JSON objects." }, { "id": "T-003", - "description": "The backend shall execute only specific whitelisted commands: `networkctl`, `journalctl`, `systemctl restart systemd-networkd`, and `systemctl reboot`." + "description": "The frontend shall render interface panels dynamically from backend JSON and allow in-place editing of configuration fields." }, { "id": "T-004", - "description": "The frontend shall consume backend endpoints via REST-style APIs returning UTF8-SON" + "description": "Configuration edits shall be validated by a backend hook that checks syntax with `systemd-analyze verify` before saving." }, { "id": "T-005", - "description": "The system shall require no external database or file storage beyond access to `systemd` configuration and logs." + "description": "All changes shall be written atomically to `/etc/systemd/network/`, with `.bak` backup created automatically." }, { "id": "T-006", - "description": "The service shall start automatically at boot and remain persistent under `systemd` supervision." + "description": "The backend shall implement privilege separation: file I/O restricted to `/etc/systemd/network/`, command execution restricted to `systemd-networkd` operations." }, { "id": "T-007", - "description": "The design shall ensure sandboxing: no direct shell access beyond intended commands, no write access to arbitrary filesystem locations." + "description": "The frontend shall provide syntax highlighting (via regex) for editing `.network` files." + }, + { + "id": "T-008", + "description": "The system shall expose REST API endpoints for: (a) listing configs, (b) retrieving config, (c) validating config, (d) saving config, (e) restarting networkd, (f) rebooting device." } ] } |
