// 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" ) var tpl = template.Must(template.ParseFiles("index.html")) // 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) } // 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, "failed to run networkctl: "+err.Error(), http.StatusInternalServerError) return } // return raw JSON (already valid) w.Header().Set("Content-Type", "application/json") w.Write(out) } // 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", "500") out, err := cmd.Output() if err != nil { http.Error(w, "failed to run journalctl: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write(out) } // 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 } 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}) } // 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, "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.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)) }