Skip to content

Commit ce6a699

Browse files
committed
webui: add Configure mode with System and Users pages
Implements the configure accordion section with a candidate datastore workflow: opening Configure copies running → candidate (Enter), each card saves directly to candidate, Apply copies candidate → running atomically, and Abort discards by resetting candidate from running. - RESTCONF client: add Put, Patch, Delete, GetDatastore, PutDatastore, CopyDatastore, and a shared doRequest/writeJSON helper - Handlers: ConfigureHandler (enter/apply/abort), ConfigureSystemHandler (hostname, clock, NTP, DNS, motd, editor), ConfigureUsersHandler (add/delete user, shell, password, SSH keys) - Sticky Apply/Abort toolbar with custom confirm dialog; server returns HX-Redirect:/ so HTMX does a full-page reload, keeping sidebar in sync - Accordion persistence fixed: Configure excluded from localStorage so it never auto-reopens after Abort/Apply; restore no longer closes an accordion that contains the active page - Password hashing by shelling out to mkpasswd(1) Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
1 parent 4848966 commit ce6a699

16 files changed

Lines changed: 1817 additions & 51 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
package handlers
4+
5+
import (
6+
"log"
7+
"net/http"
8+
9+
"github.com/kernelkit/webui/internal/restconf"
10+
)
11+
12+
// ConfigureHandler manages the candidate datastore lifecycle.
13+
type ConfigureHandler struct {
14+
RC restconf.Fetcher
15+
}
16+
17+
// Enter copies running → candidate, initialising a fresh edit session.
18+
// Called when the user opens the Configure accordion.
19+
// POST /configure/enter
20+
func (h *ConfigureHandler) Enter(w http.ResponseWriter, r *http.Request) {
21+
if err := h.RC.CopyDatastore(r.Context(), "running", "candidate"); err != nil {
22+
log.Printf("configure enter: %v", err)
23+
http.Error(w, "Could not initialise candidate datastore", http.StatusBadGateway)
24+
return
25+
}
26+
w.WriteHeader(http.StatusNoContent)
27+
}
28+
29+
// Apply copies candidate → running, activating all staged changes atomically.
30+
// POST /configure/apply
31+
func (h *ConfigureHandler) Apply(w http.ResponseWriter, r *http.Request) {
32+
if err := h.RC.CopyDatastore(r.Context(), "candidate", "running"); err != nil {
33+
log.Printf("configure apply: %v", err)
34+
http.Error(w, "Could not apply configuration: "+err.Error(), http.StatusBadGateway)
35+
return
36+
}
37+
w.Header().Set("HX-Redirect", "/")
38+
w.WriteHeader(http.StatusNoContent)
39+
}
40+
41+
// Abort copies running → candidate, discarding all staged changes.
42+
// POST /configure/abort
43+
func (h *ConfigureHandler) Abort(w http.ResponseWriter, r *http.Request) {
44+
if err := h.RC.CopyDatastore(r.Context(), "running", "candidate"); err != nil {
45+
log.Printf("configure abort: %v", err)
46+
// Best-effort reset; redirect regardless so the user can get out.
47+
}
48+
w.Header().Set("HX-Redirect", "/")
49+
w.WriteHeader(http.StatusNoContent)
50+
}
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
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

Comments
 (0)