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 /main.go | |
| parent | 650138aae6a2d6ae26f57a22056bdf0ea5fa8c77 (diff) | |
| download | network-47529804bef15ed84730ff3409f0d426fcef2112.tar.zst | |
Iteration
Diffstat (limited to 'main.go')
| -rw-r--r-- | main.go | 275 |
1 files changed, 252 insertions, 23 deletions
@@ -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)) } |
