diff --git a/src/webui/internal/handlers/configure_system.go b/src/webui/internal/handlers/configure_system.go index 719240e1..6e1d0f62 100644 --- a/src/webui/internal/handlers/configure_system.go +++ b/src/webui/internal/handlers/configure_system.go @@ -4,9 +4,11 @@ package handlers import ( "encoding/json" + "fmt" "html/template" "log" "net/http" + "net/url" "strconv" "strings" "sync" @@ -42,9 +44,11 @@ type cfgNTPJSON struct { } type cfgNTPServerJSON struct { - Name string `json:"name"` - UDP cfgNTPUDPJSON `json:"udp"` - Prefer bool `json:"prefer"` + Name string `json:"name"` + UDP cfgNTPUDPJSON `json:"udp"` + AssociationType string `json:"association-type,omitempty"` + IBurst bool `json:"iburst,omitempty"` + Prefer bool `json:"prefer,omitempty"` } type cfgNTPUDPJSON struct { @@ -89,14 +93,17 @@ type cfgSystemPageData struct { type cfgNTPPageData struct { PageData - Error string - NTP cfgNTPJSON + Error string + NTP cfgNTPJSON + AssocTypes []string // association-type enum: server / pool / peer + Desc map[string]string // leaf name → YANG description (field-info ⓘ) } type cfgDNSPageData struct { PageData Error string DNS cfgDNSJSON + Desc map[string]string // leaf name → YANG description (field-info ⓘ) } // ─── Handler ───────────────────────────────────────────────────────────────── @@ -232,6 +239,18 @@ func (h *ConfigureSystemHandler) OverviewNTP(w http.ResponseWriter, r *http.Requ data := cfgNTPPageData{ PageData: newPageData(w, r, "configure-ntp", "NTP Client"), } + data.AssocTypes = []string{"server", "pool", "peer"} + if mgr := h.Schema.Manager(); mgr != nil { + srv := "/ietf-system:system/ntp/server" + data.Desc = map[string]string{ + "name": schema.DescriptionOf(mgr, srv+"/name"), + "address": schema.DescriptionOf(mgr, srv+"/udp/address"), + "port": schema.DescriptionOf(mgr, srv+"/udp/port"), + "assoc": schema.DescriptionOf(mgr, srv+"/association-type"), + "iburst": schema.DescriptionOf(mgr, srv+"/iburst"), + "prefer": schema.DescriptionOf(mgr, srv+"/prefer"), + } + } s, errMsg := h.loadSystem(r) data.Error = errMsg if errMsg == "" { @@ -246,6 +265,15 @@ func (h *ConfigureSystemHandler) OverviewDNS(w http.ResponseWriter, r *http.Requ data := cfgDNSPageData{ PageData: newPageData(w, r, "configure-dns", "DNS Client"), } + if mgr := h.Schema.Manager(); mgr != nil { + dr := "/ietf-system:system/dns-resolver" + data.Desc = map[string]string{ + "search": schema.DescriptionOf(mgr, dr+"/search"), + "name": schema.DescriptionOf(mgr, dr+"/server/name"), + "address": schema.DescriptionOf(mgr, dr+"/server/udp-and-tcp/address"), + "port": schema.DescriptionOf(mgr, dr+"/server/udp-and-tcp/port"), + } + } s, errMsg := h.loadSystem(r) data.Error = errMsg if errMsg == "" { @@ -315,114 +343,220 @@ func (h *ConfigureSystemHandler) SaveClock(w http.ResponseWriter, r *http.Reques } // 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 - } +// ntpCandPath is the NTP container in the candidate datastore. +const ntpCandPath = candidatePath + "/ietf-system:system/ietf-system:ntp" - 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") +func ntpServerCandPath(name string) string { + return ntpCandPath + "/server=" + url.PathEscape(name) } -// SaveDNS replaces the DNS resolver config in the candidate datastore. -// PUT /configure/system/dns -func (h *ConfigureSystemHandler) SaveDNS(w http.ResponseWriter, r *http.Request) { +// ntpServerFromForm builds the RESTCONF body for one server, omitting the +// udp transport when no address is given (empty inet:host is invalid) — so a +// server can be added by name and have its address filled in via the +// expand-to-edit form afterwards. +func ntpServerFromForm(r *http.Request, name string) map[string]any { + srv := map[string]any{ + "name": name, + "association-type": defaultStr(r.FormValue("association-type"), "server"), + "iburst": r.FormValue("iburst") == "true", + "prefer": r.FormValue("prefer") == "true", + } + if addr := strings.TrimSpace(r.FormValue("address")); addr != "" { + udp := map[string]any{"address": addr} + if p, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("port")), 10, 16); err == nil && p > 0 { + udp["port"] = p + } + srv["udp"] = udp + } + return srv +} + +func defaultStr(v, def string) string { + if strings.TrimSpace(v) == "" { + return def + } + return v +} + +// AddNTPServer adds an NTP server, creating + enabling the NTP container on +// first use, then re-renders the page. +// POST /configure/system/ntp/servers +func (h *ConfigureSystemHandler) AddNTPServer(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 + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + renderSaveError(w, fmt.Errorf("server name is required")) + return } + // PATCH the system (always present) with the NTP container nested: it is + // created + enabled on first use and the server merges into the list. body := map[string]any{ - "ietf-system:dns-resolver": dnsResolver, + "ietf-system:system": map[string]any{ + "ietf-system:ntp": map[string]any{ + "enabled": true, + "server": []any{ntpServerFromForm(r, name)}, + }, + }, } - if err := h.RC.Put(r.Context(), candidatePath+"/ietf-system:system/dns-resolver", body); err != nil { - log.Printf("configure system dns: %v", err) + if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil { + log.Printf("configure ntp add server: %v", err) renderSaveError(w, err) return } - renderSaved(w, "DNS saved") + renderSavedRedirect(w, "NTP server added", "/configure/ntp") +} + +// SaveNTPServer replaces one NTP server's configuration (per-row edit). +// POST /configure/system/ntp/servers/{name} +func (h *ConfigureSystemHandler) SaveNTPServer(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + body := map[string]any{"ietf-system:server": []any{ntpServerFromForm(r, name)}} + if err := h.RC.Put(r.Context(), ntpServerCandPath(name), body); err != nil { + log.Printf("configure ntp save server %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Server saved") +} + +// DeleteNTPServer removes an NTP server. +// DELETE /configure/system/ntp/servers/{name} +func (h *ConfigureSystemHandler) DeleteNTPServer(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := h.RC.Delete(r.Context(), ntpServerCandPath(name)); err != nil && !restconf.IsNotFound(err) { + log.Printf("configure ntp delete server %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "NTP server deleted", "/configure/ntp") +} + +// dnsCandPath is the DNS resolver container in the candidate datastore. +const dnsCandPath = candidatePath + "/ietf-system:system/ietf-system:dns-resolver" + +func dnsServerCandPath(name string) string { + return dnsCandPath + "/server=" + url.PathEscape(name) +} + +func dnsServerFromForm(r *http.Request, name string) map[string]any { + srv := map[string]any{"name": name} + if addr := strings.TrimSpace(r.FormValue("address")); addr != "" { + ut := map[string]any{"address": addr} + if p, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("port")), 10, 16); err == nil && p > 0 { + ut["port"] = p + } + srv["udp-and-tcp"] = ut + } + return srv +} + +// AddDNSServer adds a DNS server, creating the resolver container on first +// use, then re-renders the page. +// POST /configure/system/dns/servers +func (h *ConfigureSystemHandler) AddDNSServer(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + renderSaveError(w, fmt.Errorf("server name is required")) + return + } + body := map[string]any{ + "ietf-system:system": map[string]any{ + "ietf-system:dns-resolver": map[string]any{"server": []any{dnsServerFromForm(r, name)}}, + }, + } + if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil { + log.Printf("configure dns add server: %v", err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "DNS server added", "/configure/dns") +} + +// SaveDNSServer replaces one DNS server's configuration (per-row edit). +// POST /configure/system/dns/servers/{name} +func (h *ConfigureSystemHandler) SaveDNSServer(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + body := map[string]any{"ietf-system:server": []any{dnsServerFromForm(r, name)}} + if err := h.RC.Put(r.Context(), dnsServerCandPath(name), body); err != nil { + log.Printf("configure dns save server %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Server saved") +} + +// DeleteDNSServer removes a DNS server. +// DELETE /configure/system/dns/servers/{name} +func (h *ConfigureSystemHandler) DeleteDNSServer(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := h.RC.Delete(r.Context(), dnsServerCandPath(name)); err != nil && !restconf.IsNotFound(err) { + log.Printf("configure dns delete server %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "DNS server deleted", "/configure/dns") +} + +// AddDNSSearch appends a search domain to the resolver's search leaf-list. +// POST /configure/system/dns/search +func (h *ConfigureSystemHandler) AddDNSSearch(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + domain := strings.TrimSpace(r.FormValue("domain")) + if domain == "" { + renderSaveError(w, fmt.Errorf("domain is required")) + return + } + // POST appends to the leaf-list; if the resolver container doesn't exist + // yet, create it with this domain via a merge on the system. + err := h.RC.PostJSON(r.Context(), dnsCandPath, map[string]any{"ietf-system:search": []any{domain}}) + if restconf.IsNotFound(err) { + err = h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", map[string]any{ + "ietf-system:system": map[string]any{ + "ietf-system:dns-resolver": map[string]any{"search": []any{domain}}, + }, + }) + } + if err != nil { + log.Printf("configure dns add search: %v", err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Search domain added", "/configure/dns") +} + +// DeleteDNSSearch removes a search domain. +// DELETE /configure/system/dns/search/{domain} +func (h *ConfigureSystemHandler) DeleteDNSSearch(w http.ResponseWriter, r *http.Request) { + domain := r.PathValue("domain") + path := dnsCandPath + "/search=" + url.PathEscape(domain) + if err := h.RC.Delete(r.Context(), path); err != nil && !restconf.IsNotFound(err) { + log.Printf("configure dns delete search %q: %v", domain, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Search domain removed", "/configure/dns") } // ─── 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 diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 766de368..0c9e60a8 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -351,8 +351,14 @@ func New( mux.HandleFunc("GET /configure/dns", cfgSys.OverviewDNS) mux.HandleFunc("POST /configure/system/identity", cfgSys.SaveIdentity) mux.HandleFunc("POST /configure/system/clock", cfgSys.SaveClock) - mux.HandleFunc("PUT /configure/system/ntp", cfgSys.SaveNTP) - mux.HandleFunc("PUT /configure/system/dns", cfgSys.SaveDNS) + mux.HandleFunc("POST /configure/system/ntp/servers", cfgSys.AddNTPServer) + mux.HandleFunc("POST /configure/system/ntp/servers/{name}", cfgSys.SaveNTPServer) + mux.HandleFunc("DELETE /configure/system/ntp/servers/{name}", cfgSys.DeleteNTPServer) + mux.HandleFunc("POST /configure/system/dns/servers", cfgSys.AddDNSServer) + mux.HandleFunc("POST /configure/system/dns/servers/{name}", cfgSys.SaveDNSServer) + mux.HandleFunc("DELETE /configure/system/dns/servers/{name}", cfgSys.DeleteDNSServer) + mux.HandleFunc("POST /configure/system/dns/search", cfgSys.AddDNSSearch) + mux.HandleFunc("DELETE /configure/system/dns/search/{domain}", cfgSys.DeleteDNSSearch) mux.HandleFunc("POST /configure/system/preferences", cfgSys.SavePreferences) mux.HandleFunc("GET /configure/interfaces", cfgIf.Overview) mux.HandleFunc("POST /configure/interfaces", cfgIf.CreateInterface) diff --git a/src/webui/templates/pages/configure-dns.html b/src/webui/templates/pages/configure-dns.html index df4c9b0a..c6d6be2e 100644 --- a/src/webui/templates/pages/configure-dns.html +++ b/src/webui/templates/pages/configure-dns.html @@ -8,75 +8,117 @@
{{.Error}}
{{end}} -
-
DNS
+
+
Search Domains
+
+ + + + {{range .DNS.Search}} + + + + + {{else}} + + {{end}} + +
Domain{{template "field-info" (index .Desc "search")}}
{{.}} + +
No search domains configured.
+
+ + +
-
Search domains
-
- - - - - - {{range $i, $d := .DNS.Search}} - - - - - {{end}} - -
Domain
- -
-
-
- -
- -
Servers
-
- - - - - - - - - - - {{range $i, $s := .DNS.Servers}} - - - - - - - {{end}} - -
Name / KeyAddressPort
- -
-
- - +
+
DNS Servers
+
+ + + + + + + + + + + {{range $i, $s := .DNS.Servers}} + + + + + + + + + + {{else}} + + {{end}} + +
Name{{template "field-info" (index .Desc "name")}}Address{{template "field-info" (index .Desc "address")}}Port{{template "field-info" (index .Desc "port")}}
+ + {{if $s.UDPAndTCP.Address}}{{$s.UDPAndTCP.Address}}{{else}}{{end}}{{if $s.UDPAndTCP.Port}}{{$s.UDPAndTCP.Port}}{{else}}53{{end}} + +
+
+
+ + + + + + + + + +
Address{{template "field-info" (index $.Desc "address")}}
Port{{template "field-info" (index $.Desc "port")}}
+ +
+
+
No DNS servers configured.
+
+ + +
{{template "configure-toolbar" .}} {{end}} diff --git a/src/webui/templates/pages/configure-ntp.html b/src/webui/templates/pages/configure-ntp.html index 28cc6772..f8dd9e80 100644 --- a/src/webui/templates/pages/configure-ntp.html +++ b/src/webui/templates/pages/configure-ntp.html @@ -8,52 +8,107 @@
{{.Error}}
{{end}} -
-
NTP Servers
-
- - - - - - - - - - - - {{range $i, $s := .NTP.Servers}} - - - - - - - - {{end}} - -
Name / KeyAddressPortPrefer
- - - -
-
- -
+
+
NTP Servers
+
+ + + + + + + + + + + + {{range $i, $s := .NTP.Servers}} + + + + + + + + + + + {{else}} + + {{end}} + +
Name{{template "field-info" (index .Desc "name")}}Address{{template "field-info" (index .Desc "address")}}Port{{template "field-info" (index .Desc "port")}}Prefer{{template "field-info" (index .Desc "prefer")}}
+ + {{if $s.UDP.Address}}{{$s.UDP.Address}}{{else}}{{end}}{{if $s.UDP.Port}}{{$s.UDP.Port}}{{else}}123{{end}}{{if $s.Prefer}}★{{end}} + +
+
+
+ + + + + + + + + + + + + + + + + + + + + +
Address{{template "field-info" (index $.Desc "address")}}
Port{{template "field-info" (index $.Desc "port")}}
Association{{template "field-info" (index $.Desc "assoc")}} + +
iburst{{template "field-info" (index $.Desc "iburst")}}
Prefer{{template "field-info" (index $.Desc "prefer")}}
+ +
+
+
No NTP servers configured.
+
+ + {{/* Hidden add-server form, revealed by the + Add server button (data-show) */}} + + +
{{template "configure-toolbar" .}} {{end}}