From 47529804bef15ed84730ff3409f0d426fcef2112 Mon Sep 17 00:00:00 2001 From: Petri Hienonen Date: Sun, 28 Sep 2025 11:16:46 +0300 Subject: Iteration --- flake.nix | 70 ++++++++++ index.html | 391 +++++++++++++++++++++++++++++++++++++++++++++++------- main.go | 275 ++++++++++++++++++++++++++++++++++---- requirements.json | 43 +++--- 4 files changed, 694 insertions(+), 85 deletions(-) create mode 100644 flake.nix 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 @@ - + + - - Systemd-networkd Control Panel + + Network UI — Control Panel + +
+

Systemd-networkd — Admin Panel

+
Single page admin + teaching panel. Auth: HTTP Basic.
+
+ +
+ + +
+
+
+
+

Network Status

+
Parsed output from networkctl status --json=short
+
+
Refresh
+
+
+
+
+ + -

Systemd-networkd Control Panel

-

This page shows network status, logs, and allows you to restart network services or reboot the device. - For more details see systemd-networkd documentation.

- -
-

1. Network Status

-

The command networkctl status --json=short shows current interfaces, addresses, and routes.

- Refresh Status -
[Waiting for data]
-
- -
-

2. Network Logs

-

Logs come from journalctl -u systemd-networkd.service. This helps diagnose DHCP and link issues. -

- Show Logs -
[Waiting for logs]
-
- -
-

3. Manage Services

-

- - Reload networkd after configuration changes.
- - Reboot device if required. -

- Restart networkd - Reboot device -
+ + + +
+ /* initial */ + loadStatus(); + 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/ + 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." } ] } -- cgit v1.2.3-70-g09d2