|
| 1 | +// SPDX-License-Identifier: MIT |
| 2 | + |
| 3 | +package handlers |
| 4 | + |
| 5 | +import ( |
| 6 | + "encoding/json" |
| 7 | + "errors" |
| 8 | + "html/template" |
| 9 | + "log" |
| 10 | + "net/http" |
| 11 | + "strconv" |
| 12 | + "strings" |
| 13 | + |
| 14 | + "github.com/kernelkit/webui/internal/restconf" |
| 15 | +) |
| 16 | + |
| 17 | +// ─── RESTCONF JSON types (candidate datastore) ──────────────────────────────── |
| 18 | + |
| 19 | +type cfgSystemWrapper struct { |
| 20 | + System cfgSystemJSON `json:"ietf-system:system"` |
| 21 | +} |
| 22 | + |
| 23 | +type cfgSystemJSON struct { |
| 24 | + Contact string `json:"contact"` |
| 25 | + Hostname string `json:"hostname"` |
| 26 | + Location string `json:"location"` |
| 27 | + Clock cfgClockJSON `json:"clock"` |
| 28 | + NTP cfgNTPJSON `json:"ntp"` |
| 29 | + DNS cfgDNSJSON `json:"dns-resolver"` |
| 30 | + MotdBanner []byte `json:"infix-system:motd-banner,omitempty"` |
| 31 | + TextEditor string `json:"infix-system:text-editor,omitempty"` |
| 32 | +} |
| 33 | + |
| 34 | +type cfgClockJSON struct { |
| 35 | + TimezoneName string `json:"timezone-name"` |
| 36 | +} |
| 37 | + |
| 38 | +type cfgNTPJSON struct { |
| 39 | + Enabled bool `json:"enabled"` |
| 40 | + Servers []cfgNTPServerJSON `json:"server"` |
| 41 | +} |
| 42 | + |
| 43 | +type cfgNTPServerJSON struct { |
| 44 | + Name string `json:"name"` |
| 45 | + UDP cfgNTPUDPJSON `json:"udp"` |
| 46 | + Prefer bool `json:"prefer"` |
| 47 | +} |
| 48 | + |
| 49 | +type cfgNTPUDPJSON struct { |
| 50 | + Address string `json:"address"` |
| 51 | + Port uint16 `json:"port,omitempty"` |
| 52 | +} |
| 53 | + |
| 54 | +type cfgDNSJSON struct { |
| 55 | + Search []string `json:"search"` |
| 56 | + Servers []cfgDNSServerJSON `json:"server"` |
| 57 | +} |
| 58 | + |
| 59 | +type cfgDNSServerJSON struct { |
| 60 | + Name string `json:"name"` |
| 61 | + UDPAndTCP cfgDNSAddrJSON `json:"udp-and-tcp"` |
| 62 | +} |
| 63 | + |
| 64 | +type cfgDNSAddrJSON struct { |
| 65 | + Address string `json:"address"` |
| 66 | + Port uint16 `json:"port,omitempty"` |
| 67 | +} |
| 68 | + |
| 69 | +// ─── Template data ──────────────────────────────────────────────────────────── |
| 70 | + |
| 71 | +type cfgSystemPageData struct { |
| 72 | + PageData |
| 73 | + Error string |
| 74 | + Hostname string |
| 75 | + Contact string |
| 76 | + Location string |
| 77 | + Timezone string |
| 78 | + NTP cfgNTPJSON |
| 79 | + DNS cfgDNSJSON |
| 80 | + MotdBanner string // decoded from YANG binary |
| 81 | + TextEditor string // e.g. "infix-system:emacs" |
| 82 | +} |
| 83 | + |
| 84 | +// ─── Handler ───────────────────────────────────────────────────────────────── |
| 85 | + |
| 86 | +// ConfigureSystemHandler serves the Configure > System page. |
| 87 | +type ConfigureSystemHandler struct { |
| 88 | + Template *template.Template |
| 89 | + RC restconf.Fetcher |
| 90 | +} |
| 91 | + |
| 92 | +const candidatePath = "/ds/ietf-datastores:candidate" |
| 93 | + |
| 94 | +// Overview renders the Configure > System page reading from the candidate datastore. |
| 95 | +// GET /configure/system |
| 96 | +func (h *ConfigureSystemHandler) Overview(w http.ResponseWriter, r *http.Request) { |
| 97 | + data := cfgSystemPageData{ |
| 98 | + PageData: newPageData(r, "configure-system", "Configure: System"), |
| 99 | + } |
| 100 | + |
| 101 | + var raw cfgSystemWrapper |
| 102 | + if err := h.RC.Get(r.Context(), candidatePath+"/ietf-system:system", &raw); err != nil { |
| 103 | + var rcErr *restconf.Error |
| 104 | + if errors.As(err, &rcErr) && rcErr.StatusCode == http.StatusNotFound { |
| 105 | + // Candidate not initialised — read from running as fallback. |
| 106 | + if fallErr := h.RC.Get(r.Context(), "/data/ietf-system:system", &raw); fallErr != nil { |
| 107 | + var rcFall *restconf.Error |
| 108 | + if !errors.As(fallErr, &rcFall) || rcFall.StatusCode != http.StatusNotFound { |
| 109 | + log.Printf("configure system (running fallback): %v", fallErr) |
| 110 | + data.Error = "Could not read system configuration" |
| 111 | + } |
| 112 | + } |
| 113 | + } else { |
| 114 | + log.Printf("configure system: %v", err) |
| 115 | + data.Error = "Could not read candidate configuration" |
| 116 | + } |
| 117 | + } |
| 118 | + if data.Error == "" { |
| 119 | + s := raw.System |
| 120 | + data.Hostname = s.Hostname |
| 121 | + data.Contact = s.Contact |
| 122 | + data.Location = s.Location |
| 123 | + data.Timezone = s.Clock.TimezoneName |
| 124 | + data.NTP = s.NTP |
| 125 | + data.DNS = s.DNS |
| 126 | + data.MotdBanner = string(s.MotdBanner) |
| 127 | + data.TextEditor = s.TextEditor |
| 128 | + } |
| 129 | + |
| 130 | + tmplName := "configure-system.html" |
| 131 | + if r.Header.Get("HX-Request") == "true" { |
| 132 | + tmplName = "content" |
| 133 | + } |
| 134 | + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { |
| 135 | + log.Printf("template error: %v", err) |
| 136 | + http.Error(w, "Internal server error", http.StatusInternalServerError) |
| 137 | + } |
| 138 | +} |
| 139 | + |
| 140 | +// SaveIdentity patches hostname / contact / location to the candidate datastore. |
| 141 | +// POST /configure/system/identity |
| 142 | +func (h *ConfigureSystemHandler) SaveIdentity(w http.ResponseWriter, r *http.Request) { |
| 143 | + if err := r.ParseForm(); err != nil { |
| 144 | + http.Error(w, "bad request", http.StatusBadRequest) |
| 145 | + return |
| 146 | + } |
| 147 | + |
| 148 | + body := map[string]any{ |
| 149 | + "ietf-system:system": map[string]any{ |
| 150 | + "hostname": r.FormValue("hostname"), |
| 151 | + "contact": r.FormValue("contact"), |
| 152 | + "location": r.FormValue("location"), |
| 153 | + }, |
| 154 | + } |
| 155 | + if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil { |
| 156 | + log.Printf("configure system identity: %v", err) |
| 157 | + renderSaveError(w, err) |
| 158 | + return |
| 159 | + } |
| 160 | + renderSaved(w, "Identity saved") |
| 161 | +} |
| 162 | + |
| 163 | +// SaveClock patches the timezone to the candidate datastore. |
| 164 | +// POST /configure/system/clock |
| 165 | +func (h *ConfigureSystemHandler) SaveClock(w http.ResponseWriter, r *http.Request) { |
| 166 | + if err := r.ParseForm(); err != nil { |
| 167 | + http.Error(w, "bad request", http.StatusBadRequest) |
| 168 | + return |
| 169 | + } |
| 170 | + |
| 171 | + body := map[string]any{ |
| 172 | + "ietf-system:system": map[string]any{ |
| 173 | + "clock": map[string]any{ |
| 174 | + "timezone-name": r.FormValue("timezone"), |
| 175 | + }, |
| 176 | + }, |
| 177 | + } |
| 178 | + if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil { |
| 179 | + log.Printf("configure system clock: %v", err) |
| 180 | + renderSaveError(w, err) |
| 181 | + return |
| 182 | + } |
| 183 | + renderSaved(w, "Clock saved") |
| 184 | +} |
| 185 | + |
| 186 | +// SaveNTP replaces the NTP server list in the candidate datastore. |
| 187 | +// PUT /configure/system/ntp |
| 188 | +func (h *ConfigureSystemHandler) SaveNTP(w http.ResponseWriter, r *http.Request) { |
| 189 | + if err := r.ParseForm(); err != nil { |
| 190 | + http.Error(w, "bad request", http.StatusBadRequest) |
| 191 | + return |
| 192 | + } |
| 193 | + |
| 194 | + servers := parseNTPServers(r) |
| 195 | + ntp := map[string]any{"enabled": true} |
| 196 | + if len(servers) > 0 { |
| 197 | + ntp["server"] = servers |
| 198 | + } |
| 199 | + body := map[string]any{"ietf-system:ntp": ntp} |
| 200 | + if err := h.RC.Put(r.Context(), candidatePath+"/ietf-system:system/ntp", body); err != nil { |
| 201 | + log.Printf("configure system ntp: %v", err) |
| 202 | + renderSaveError(w, err) |
| 203 | + return |
| 204 | + } |
| 205 | + renderSaved(w, "NTP saved") |
| 206 | +} |
| 207 | + |
| 208 | +// SaveDNS replaces the DNS resolver config in the candidate datastore. |
| 209 | +// PUT /configure/system/dns |
| 210 | +func (h *ConfigureSystemHandler) SaveDNS(w http.ResponseWriter, r *http.Request) { |
| 211 | + if err := r.ParseForm(); err != nil { |
| 212 | + http.Error(w, "bad request", http.StatusBadRequest) |
| 213 | + return |
| 214 | + } |
| 215 | + |
| 216 | + search := parseSearchList(r) |
| 217 | + servers := parseDNSServers(r) |
| 218 | + |
| 219 | + // Omit empty lists entirely — sending null for a YANG list is invalid. |
| 220 | + dnsResolver := map[string]any{} |
| 221 | + if len(search) > 0 { |
| 222 | + dnsResolver["search"] = search |
| 223 | + } |
| 224 | + if len(servers) > 0 { |
| 225 | + dnsResolver["server"] = servers |
| 226 | + } |
| 227 | + body := map[string]any{ |
| 228 | + "ietf-system:dns-resolver": dnsResolver, |
| 229 | + } |
| 230 | + if err := h.RC.Put(r.Context(), candidatePath+"/ietf-system:system/dns-resolver", body); err != nil { |
| 231 | + log.Printf("configure system dns: %v", err) |
| 232 | + renderSaveError(w, err) |
| 233 | + return |
| 234 | + } |
| 235 | + renderSaved(w, "DNS saved") |
| 236 | +} |
| 237 | + |
| 238 | +// ─── Form parsing helpers ───────────────────────────────────────────────────── |
| 239 | + |
| 240 | +// parseNTPServers extracts NTP server entries from form values. |
| 241 | +// Fields: ntp_name_N, ntp_addr_N, ntp_port_N, ntp_prefer_N (checkbox). |
| 242 | +func parseNTPServers(r *http.Request) []cfgNTPServerJSON { |
| 243 | + var servers []cfgNTPServerJSON |
| 244 | + for i := 0; ; i++ { |
| 245 | + name := strings.TrimSpace(r.FormValue("ntp_name_" + strconv.Itoa(i))) |
| 246 | + if name == "" { |
| 247 | + break |
| 248 | + } |
| 249 | + addr := strings.TrimSpace(r.FormValue("ntp_addr_" + strconv.Itoa(i))) |
| 250 | + port, _ := strconv.ParseUint(r.FormValue("ntp_port_"+strconv.Itoa(i)), 10, 16) |
| 251 | + prefer := r.FormValue("ntp_prefer_"+strconv.Itoa(i)) == "on" |
| 252 | + srv := cfgNTPServerJSON{ |
| 253 | + Name: name, |
| 254 | + UDP: cfgNTPUDPJSON{Address: addr, Port: uint16(port)}, |
| 255 | + Prefer: prefer, |
| 256 | + } |
| 257 | + servers = append(servers, srv) |
| 258 | + } |
| 259 | + return servers |
| 260 | +} |
| 261 | + |
| 262 | +// parseSearchList extracts DNS search domains from form values. |
| 263 | +// Fields: dns_search_N (one per domain). |
| 264 | +func parseSearchList(r *http.Request) []string { |
| 265 | + var search []string |
| 266 | + for i := 0; ; i++ { |
| 267 | + v := strings.TrimSpace(r.FormValue("dns_search_" + strconv.Itoa(i))) |
| 268 | + if v == "" { |
| 269 | + break |
| 270 | + } |
| 271 | + search = append(search, v) |
| 272 | + } |
| 273 | + return search |
| 274 | +} |
| 275 | + |
| 276 | +// parseDNSServers extracts DNS server entries from form values. |
| 277 | +// Fields: dns_name_N, dns_addr_N, dns_port_N. |
| 278 | +func parseDNSServers(r *http.Request) []cfgDNSServerJSON { |
| 279 | + var servers []cfgDNSServerJSON |
| 280 | + for i := 0; ; i++ { |
| 281 | + name := strings.TrimSpace(r.FormValue("dns_name_" + strconv.Itoa(i))) |
| 282 | + if name == "" { |
| 283 | + break |
| 284 | + } |
| 285 | + addr := strings.TrimSpace(r.FormValue("dns_addr_" + strconv.Itoa(i))) |
| 286 | + port, _ := strconv.ParseUint(r.FormValue("dns_port_"+strconv.Itoa(i)), 10, 16) |
| 287 | + srv := cfgDNSServerJSON{ |
| 288 | + Name: name, |
| 289 | + UDPAndTCP: cfgDNSAddrJSON{Address: addr, Port: uint16(port)}, |
| 290 | + } |
| 291 | + servers = append(servers, srv) |
| 292 | + } |
| 293 | + return servers |
| 294 | +} |
| 295 | + |
| 296 | +// SavePreferences patches infix-system augmented fields (motd-banner, text-editor). |
| 297 | +// POST /configure/system/preferences |
| 298 | +func (h *ConfigureSystemHandler) SavePreferences(w http.ResponseWriter, r *http.Request) { |
| 299 | + if err := r.ParseForm(); err != nil { |
| 300 | + http.Error(w, "bad request", http.StatusBadRequest) |
| 301 | + return |
| 302 | + } |
| 303 | + |
| 304 | + sysPatch := map[string]any{} |
| 305 | + if motd := r.FormValue("motd_banner"); motd != "" { |
| 306 | + sysPatch["infix-system:motd-banner"] = []byte(motd) |
| 307 | + } |
| 308 | + if editor := r.FormValue("text_editor"); editor != "" { |
| 309 | + sysPatch["infix-system:text-editor"] = editor |
| 310 | + } |
| 311 | + if len(sysPatch) == 0 { |
| 312 | + renderSaved(w, "Preferences saved") |
| 313 | + return |
| 314 | + } |
| 315 | + |
| 316 | + body := map[string]any{"ietf-system:system": sysPatch} |
| 317 | + if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil { |
| 318 | + log.Printf("configure system preferences: %v", err) |
| 319 | + renderSaveError(w, err) |
| 320 | + return |
| 321 | + } |
| 322 | + renderSaved(w, "Preferences saved") |
| 323 | +} |
| 324 | + |
| 325 | +// ─── Response helpers ───────────────────────────────────────────────────────── |
| 326 | + |
| 327 | +// renderSaved writes a success indicator for HTMX to swap into the Save button. |
| 328 | +func renderSaved(w http.ResponseWriter, msg string) { |
| 329 | + w.Header().Set("Content-Type", "text/html") |
| 330 | + w.Header().Set("HX-Trigger", `{"cfgSaved":"`+msg+`"}`) |
| 331 | + w.WriteHeader(http.StatusOK) |
| 332 | +} |
| 333 | + |
| 334 | +// renderSaveError writes an inline error message for HTMX to swap. |
| 335 | +func renderSaveError(w http.ResponseWriter, err error) { |
| 336 | + msg := "Save failed" |
| 337 | + if re, ok := err.(*restconf.Error); ok && re.Message != "" { |
| 338 | + msg = re.Message |
| 339 | + } |
| 340 | + w.Header().Set("Content-Type", "text/html") |
| 341 | + w.WriteHeader(http.StatusUnprocessableEntity) |
| 342 | + // Encode the message safely for HTML output. |
| 343 | + b, _ := json.Marshal(msg) |
| 344 | + _ = b // used below via template-escaped string |
| 345 | + w.Write([]byte(`<span class="cfg-save-error">` + template.HTMLEscapeString(msg) + `</span>`)) |
| 346 | +} |
0 commit comments