Files
infix/src/webui/internal/handlers/configure_system.go
T
Joachim Wiberg d9611c48a9 webui: rename Go module to infix/webui
The webui Go code now lives in the infix source tree rather than being
imported as an external module.  Switch the module path to match that
reality so the imports read coherently with the rest of the codebase.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-06-15 19:45:20 +02:00

389 lines
13 KiB
Go

// SPDX-License-Identifier: MIT
package handlers
import (
"encoding/json"
"html/template"
"log"
"net/http"
"strconv"
"strings"
"infix/webui/internal/restconf"
"infix/webui/internal/schema"
)
// ─── RESTCONF JSON types (candidate datastore) ────────────────────────────────
type cfgSystemWrapper struct {
System cfgSystemJSON `json:"ietf-system:system"`
}
type cfgSystemJSON struct {
Contact string `json:"contact"`
Hostname string `json:"hostname"`
Location string `json:"location"`
Clock cfgClockJSON `json:"clock"`
NTP cfgNTPJSON `json:"ntp"`
DNS cfgDNSJSON `json:"dns-resolver"`
MotdBanner []byte `json:"infix-system:motd-banner,omitempty"`
TextEditor string `json:"infix-system:text-editor,omitempty"`
}
type cfgClockJSON struct {
TimezoneName string `json:"timezone-name"`
}
type cfgNTPJSON struct {
Enabled bool `json:"enabled"`
Servers []cfgNTPServerJSON `json:"server"`
}
type cfgNTPServerJSON struct {
Name string `json:"name"`
UDP cfgNTPUDPJSON `json:"udp"`
Prefer bool `json:"prefer"`
}
type cfgNTPUDPJSON struct {
Address string `json:"address"`
Port uint16 `json:"port,omitempty"`
}
type cfgDNSJSON struct {
Search []string `json:"search"`
Servers []cfgDNSServerJSON `json:"server"`
}
type cfgDNSServerJSON struct {
Name string `json:"name"`
UDPAndTCP cfgDNSAddrJSON `json:"udp-and-tcp"`
}
type cfgDNSAddrJSON struct {
Address string `json:"address"`
Port uint16 `json:"port,omitempty"`
}
// ─── Template data ────────────────────────────────────────────────────────────
type cfgSystemPageData struct {
PageData
Loading bool // true while YANG schema is still downloading
Error string
Hostname string
Contact string
Location string
Timezone string
NTP cfgNTPJSON
DNS cfgDNSJSON
MotdBanner string // decoded from YANG binary
TextEditor string // e.g. "infix-system:emacs"
// Schema-enriched fields — only populated when Loading is false.
TextEditorOptions []schema.IdentityOption
TimezoneOptions []string // bare timezone names for select
Desc map[string]string // leaf name → YANG description
}
// ─── Handler ─────────────────────────────────────────────────────────────────
// ConfigureSystemHandler serves the Configure > System page.
type ConfigureSystemHandler struct {
Template *template.Template
RC restconf.Fetcher
Schema *schema.Cache
}
const candidatePath = "/ds/ietf-datastores:candidate"
// Overview renders the Configure > System page reading from the candidate datastore.
// GET /configure/system
func (h *ConfigureSystemHandler) Overview(w http.ResponseWriter, r *http.Request) {
data := cfgSystemPageData{
PageData: newPageData(r, "configure-system", "Configure: System"),
}
var raw cfgSystemWrapper
if err := h.RC.Get(r.Context(), candidatePath+"/ietf-system:system", &raw); err != nil {
if !restconf.IsNotFound(err) {
log.Printf("configure system: %v", err)
data.Error = "Could not read candidate configuration"
} else if fallErr := h.RC.Get(r.Context(), "/data/ietf-system:system", &raw); fallErr != nil && !restconf.IsNotFound(fallErr) {
// Candidate not initialised — fall back to running; only real errors surface.
log.Printf("configure system (running fallback): %v", fallErr)
data.Error = "Could not read system configuration"
}
}
if data.Error == "" {
s := raw.System
data.Hostname = s.Hostname
data.Contact = s.Contact
data.Location = s.Location
data.Timezone = s.Clock.TimezoneName
data.NTP = s.NTP
data.DNS = s.DNS
data.MotdBanner = string(s.MotdBanner)
data.TextEditor = s.TextEditor
}
mgr := h.Schema.Manager()
data.Loading = mgr == nil
if mgr != nil {
const sys = "/ietf-system:system"
data.TextEditorOptions = schema.OptionsFor(mgr, sys+"/infix-system:text-editor")
if data.TextEditor == "" {
for _, opt := range data.TextEditorOptions {
if opt.IsDefault {
data.TextEditor = opt.Value
break
}
}
}
data.Desc = map[string]string{
"hostname": schema.DescriptionOf(mgr, sys+"/hostname"),
"contact": schema.DescriptionOf(mgr, sys+"/contact"),
"location": schema.DescriptionOf(mgr, sys+"/location"),
"timezone": schema.DescriptionOf(mgr, sys+"/clock/timezone-name"),
"text-editor": schema.DescriptionOf(mgr, sys+"/infix-system:text-editor"),
"motd-banner": schema.DescriptionOf(mgr, sys+"/infix-system:motd-banner"),
}
for _, opt := range schema.OptionsFor(mgr, sys+"/clock/timezone-name") {
data.TimezoneOptions = append(data.TimezoneOptions, schema.StripModulePrefix(opt.Value))
}
}
tmplName := "configure-system.html"
if r.Header.Get("HX-Request") == "true" {
tmplName = "content"
}
if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// SaveIdentity patches hostname / contact / location to the candidate datastore.
// POST /configure/system/identity
func (h *ConfigureSystemHandler) SaveIdentity(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
body := map[string]any{
"ietf-system:system": map[string]any{
"hostname": r.FormValue("hostname"),
"contact": r.FormValue("contact"),
"location": r.FormValue("location"),
},
}
if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil {
log.Printf("configure system identity: %v", err)
renderSaveError(w, err)
return
}
renderSaved(w, "Identity saved")
}
// SaveClock patches the timezone to the candidate datastore.
// POST /configure/system/clock
func (h *ConfigureSystemHandler) SaveClock(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
body := map[string]any{
"ietf-system:system": map[string]any{
"clock": map[string]any{
"timezone-name": r.FormValue("timezone"),
},
},
}
if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil {
log.Printf("configure system clock: %v", err)
renderSaveError(w, err)
return
}
renderSaved(w, "Clock saved")
}
// SaveNTP replaces the NTP server list in the candidate datastore.
// PUT /configure/system/ntp
func (h *ConfigureSystemHandler) SaveNTP(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
servers := parseNTPServers(r)
ntp := map[string]any{"enabled": true}
if len(servers) > 0 {
ntp["server"] = servers
}
body := map[string]any{"ietf-system:ntp": ntp}
if err := h.RC.Put(r.Context(), candidatePath+"/ietf-system:system/ntp", body); err != nil {
log.Printf("configure system ntp: %v", err)
renderSaveError(w, err)
return
}
renderSaved(w, "NTP saved")
}
// SaveDNS replaces the DNS resolver config in the candidate datastore.
// PUT /configure/system/dns
func (h *ConfigureSystemHandler) SaveDNS(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
search := parseSearchList(r)
servers := parseDNSServers(r)
// Omit empty lists entirely — sending null for a YANG list is invalid.
dnsResolver := map[string]any{}
if len(search) > 0 {
dnsResolver["search"] = search
}
if len(servers) > 0 {
dnsResolver["server"] = servers
}
body := map[string]any{
"ietf-system:dns-resolver": dnsResolver,
}
if err := h.RC.Put(r.Context(), candidatePath+"/ietf-system:system/dns-resolver", body); err != nil {
log.Printf("configure system dns: %v", err)
renderSaveError(w, err)
return
}
renderSaved(w, "DNS saved")
}
// ─── Form parsing helpers ─────────────────────────────────────────────────────
// parseNTPServers extracts NTP server entries from form values.
// Fields: ntp_name_N, ntp_addr_N, ntp_port_N, ntp_prefer_N (checkbox).
func parseNTPServers(r *http.Request) []cfgNTPServerJSON {
var servers []cfgNTPServerJSON
for i := 0; ; i++ {
name := strings.TrimSpace(r.FormValue("ntp_name_" + strconv.Itoa(i)))
if name == "" {
break
}
addr := strings.TrimSpace(r.FormValue("ntp_addr_" + strconv.Itoa(i)))
port, _ := strconv.ParseUint(r.FormValue("ntp_port_"+strconv.Itoa(i)), 10, 16)
prefer := r.FormValue("ntp_prefer_"+strconv.Itoa(i)) == "on"
srv := cfgNTPServerJSON{
Name: name,
UDP: cfgNTPUDPJSON{Address: addr, Port: uint16(port)},
Prefer: prefer,
}
servers = append(servers, srv)
}
return servers
}
// parseSearchList extracts DNS search domains from form values.
// Fields: dns_search_N (one per domain).
func parseSearchList(r *http.Request) []string {
var search []string
for i := 0; ; i++ {
v := strings.TrimSpace(r.FormValue("dns_search_" + strconv.Itoa(i)))
if v == "" {
break
}
search = append(search, v)
}
return search
}
// parseDNSServers extracts DNS server entries from form values.
// Fields: dns_name_N, dns_addr_N, dns_port_N.
func parseDNSServers(r *http.Request) []cfgDNSServerJSON {
var servers []cfgDNSServerJSON
for i := 0; ; i++ {
name := strings.TrimSpace(r.FormValue("dns_name_" + strconv.Itoa(i)))
if name == "" {
break
}
addr := strings.TrimSpace(r.FormValue("dns_addr_" + strconv.Itoa(i)))
port, _ := strconv.ParseUint(r.FormValue("dns_port_"+strconv.Itoa(i)), 10, 16)
srv := cfgDNSServerJSON{
Name: name,
UDPAndTCP: cfgDNSAddrJSON{Address: addr, Port: uint16(port)},
}
servers = append(servers, srv)
}
return servers
}
// SavePreferences patches infix-system augmented fields (motd-banner, text-editor).
// POST /configure/system/preferences
func (h *ConfigureSystemHandler) SavePreferences(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
sysPatch := map[string]any{}
if motd := r.FormValue("motd_banner"); motd != "" {
sysPatch["infix-system:motd-banner"] = []byte(motd)
}
if editor := r.FormValue("text_editor"); editor != "" {
sysPatch["infix-system:text-editor"] = editor
}
if len(sysPatch) == 0 {
renderSaved(w, "Preferences saved")
return
}
body := map[string]any{"ietf-system:system": sysPatch}
if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil {
log.Printf("configure system preferences: %v", err)
renderSaveError(w, err)
return
}
renderSaved(w, "Preferences saved")
}
// ─── Response helpers ─────────────────────────────────────────────────────────
// renderSaved writes a success indicator for HTMX to swap into the Save button.
func renderSaved(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "text/html")
w.Header().Set("HX-Trigger", `{"cfgSaved":"`+msg+`"}`)
w.WriteHeader(http.StatusOK)
}
// renderSavedRedirect logs a cfgSaved activity entry and then navigates HTMX to
// the given page path (targeting #content). Use this instead of a bare HX-Location
// for Add/Delete operations that redirect back to the listing page after success.
func renderSavedRedirect(w http.ResponseWriter, msg, path string) {
b, _ := json.Marshal(msg)
w.Header().Set("HX-Trigger", `{"cfgSaved":`+string(b)+`}`)
w.Header().Set("HX-Location", `{"path":"`+path+`","target":"#content"}`)
w.WriteHeader(http.StatusNoContent)
}
// renderSaveError writes an inline error for HTMX. HX-Trigger ensures forms with
// hx-swap="none" still receive the cfgError event (body swap alone would be silenced).
// RESTCONF errors surface their server-side Message; all other errors fall back to
// err.Error() so handler-level validation messages reach the user instead of being
// flattened to a generic "Save failed".
func renderSaveError(w http.ResponseWriter, err error) {
msg := "Save failed"
if re, ok := err.(*restconf.Error); ok && re.Message != "" {
msg = re.Message
} else if err != nil && err.Error() != "" {
msg = err.Error()
}
w.Header().Set("Content-Type", "text/html")
b, _ := json.Marshal(msg)
w.Header().Set("HX-Trigger", `{"cfgError":`+string(b)+`}`)
w.WriteHeader(http.StatusUnprocessableEntity)
w.Write([]byte(`<span class="cfg-save-error">` + template.HTMLEscapeString(msg) + `</span>`))
}