summaryrefslogtreecommitdiffstats
path: root/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'main.go')
-rw-r--r--main.go275
1 files changed, 252 insertions, 23 deletions
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))
}