summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-09-28 11:16:46 +0300
committerPetri Hienonen <petri.hienonen@gmail.com>2025-09-28 11:16:46 +0300
commit47529804bef15ed84730ff3409f0d426fcef2112 (patch)
tree9f2d786eb5082370025307c07c06354a72e2e007
parent650138aae6a2d6ae26f57a22056bdf0ea5fa8c77 (diff)
downloadnetwork-47529804bef15ed84730ff3409f0d426fcef2112.tar.zst
Iteration
-rw-r--r--flake.nix70
-rw-r--r--index.html391
-rw-r--r--main.go275
-rw-r--r--requirements.json43
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;
+ };
+ };
+ };
+ };
+ });
+}
diff --git a/index.html b/index.html
index 618f523..f516f7c 100644
--- a/index.html
+++ b/index.html
@@ -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>
diff --git a/main.go b/main.go
index 046ad91..8b2ca32 100644
--- a/main.go
+++ b/main.go
@@ -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."
}
]
}