diff --git a/src/webui/internal/handlers/configure.go b/src/webui/internal/handlers/configure.go new file mode 100644 index 00000000..c3fb0c68 --- /dev/null +++ b/src/webui/internal/handlers/configure.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "log" + "net/http" + + "github.com/kernelkit/webui/internal/restconf" +) + +// ConfigureHandler manages the candidate datastore lifecycle. +type ConfigureHandler struct { + RC restconf.Fetcher +} + +// Enter copies running → candidate, initialising a fresh edit session. +// Called when the user opens the Configure accordion. +// POST /configure/enter +func (h *ConfigureHandler) Enter(w http.ResponseWriter, r *http.Request) { + if err := h.RC.CopyDatastore(r.Context(), "running", "candidate"); err != nil { + log.Printf("configure enter: %v", err) + http.Error(w, "Could not initialise candidate datastore", http.StatusBadGateway) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// Apply copies candidate → running, activating all staged changes atomically. +// POST /configure/apply +func (h *ConfigureHandler) Apply(w http.ResponseWriter, r *http.Request) { + if err := h.RC.CopyDatastore(r.Context(), "candidate", "running"); err != nil { + log.Printf("configure apply: %v", err) + http.Error(w, "Could not apply configuration: "+err.Error(), http.StatusBadGateway) + return + } + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusNoContent) +} + +// Abort copies running → candidate, discarding all staged changes. +// POST /configure/abort +func (h *ConfigureHandler) Abort(w http.ResponseWriter, r *http.Request) { + if err := h.RC.CopyDatastore(r.Context(), "running", "candidate"); err != nil { + log.Printf("configure abort: %v", err) + // Best-effort reset; redirect regardless so the user can get out. + } + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusNoContent) +} + +// ApplyAndSave copies candidate → running then running → startup in one step. +// POST /configure/apply-and-save +func (h *ConfigureHandler) ApplyAndSave(w http.ResponseWriter, r *http.Request) { + if err := h.RC.CopyDatastore(r.Context(), "candidate", "running"); err != nil { + log.Printf("configure apply-and-save: %v", err) + http.Error(w, "Could not apply configuration: "+err.Error(), http.StatusBadGateway) + return + } + if err := h.RC.CopyDatastore(r.Context(), "running", "startup"); err != nil { + log.Printf("configure apply-and-save (save): %v", err) + http.Error(w, "Could not save configuration: "+err.Error(), http.StatusBadGateway) + return + } + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusNoContent) +} diff --git a/src/webui/internal/handlers/configure_system.go b/src/webui/internal/handlers/configure_system.go new file mode 100644 index 00000000..6079bef2 --- /dev/null +++ b/src/webui/internal/handlers/configure_system.go @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "encoding/json" + "errors" + "html/template" + "log" + "net/http" + "strconv" + "strings" + + "github.com/kernelkit/webui/internal/restconf" +) + +// ─── 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 + 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" +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// ConfigureSystemHandler serves the Configure > System page. +type ConfigureSystemHandler struct { + Template *template.Template + RC restconf.Fetcher +} + +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 { + var rcErr *restconf.Error + if errors.As(err, &rcErr) && rcErr.StatusCode == http.StatusNotFound { + // Candidate not initialised — read from running as fallback. + if fallErr := h.RC.Get(r.Context(), "/data/ietf-system:system", &raw); fallErr != nil { + var rcFall *restconf.Error + if !errors.As(fallErr, &rcFall) || rcFall.StatusCode != http.StatusNotFound { + log.Printf("configure system (running fallback): %v", fallErr) + data.Error = "Could not read system configuration" + } + } + } else { + log.Printf("configure system: %v", err) + data.Error = "Could not read candidate 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 + } + + 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) +} + +// 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") + w.WriteHeader(http.StatusUnprocessableEntity) + // Encode the message safely for HTML output. + b, _ := json.Marshal(msg) + _ = b // used below via template-escaped string + w.Write([]byte(`` + template.HTMLEscapeString(msg) + ``)) +} diff --git a/src/webui/internal/handlers/configure_users.go b/src/webui/internal/handlers/configure_users.go new file mode 100644 index 00000000..fd511a35 --- /dev/null +++ b/src/webui/internal/handlers/configure_users.go @@ -0,0 +1,530 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "encoding/base64" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "strings" + + "github.com/kernelkit/webui/internal/restconf" + "github.com/kernelkit/webui/internal/schema" +) + +// ─── RESTCONF JSON types ────────────────────────────────────────────────────── + +// cfgAuthWrapper reads the authentication container via the full system path, +// which avoids sub-path encoding ambiguities and matches what configure-system uses. +type cfgAuthWrapper struct { + System struct { + Auth cfgAuthJSON `json:"authentication"` + } `json:"ietf-system:system"` +} + +type cfgAuthJSON struct { + Users []cfgUserJSON `json:"user"` +} + +type cfgUserJSON struct { + Name string `json:"name"` + Password string `json:"password,omitempty"` + Shell string `json:"infix-system:shell,omitempty"` + AuthorizedKeys []cfgKeyJSON `json:"authorized-key,omitempty"` +} + +type cfgKeyJSON struct { + Name string `json:"name"` + Algorithm string `json:"algorithm"` + KeyData []byte `json:"key-data"` +} + +// ─── Display helpers ────────────────────────────────────────────────────────── + +type cfgUserDisplay struct { + cfgUserJSON + ShellLabel string + KeyCount int +} + +type cfgGroupDisplay struct { + nacmGroupJSON + MembersSummary string + Available []string // users not currently in this group +} + +// ─── Template data ──────────────────────────────────────────────────────────── + +type cfgUsersPageData struct { + PageData + Loading bool + Users []cfgUserDisplay + Groups []cfgGroupDisplay + Error string + ShellOptions []schema.IdentityOption +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// ConfigureUsersHandler serves the Configure > Users page. +type ConfigureUsersHandler struct { + Template *template.Template + RC restconf.Fetcher + Schema *schema.Cache +} + +const authPath = candidatePath + "/ietf-system:system/authentication" +const nacmGroupsPath = candidatePath + "/ietf-netconf-acm:nacm/groups" + +// Overview renders the Configure > Users page reading from the candidate. +// GET /configure/users +func (h *ConfigureUsersHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := cfgUsersPageData{ + PageData: newPageData(r, "configure-users", "Configure: Users & Groups"), + } + + // Read via the full system path (same as configure-system) to avoid + // sub-path encoding issues. Fall back to running if candidate is empty. + sysPath := candidatePath + "/ietf-system:system" + var raw cfgAuthWrapper + if err := h.RC.Get(r.Context(), sysPath, &raw); err != nil { + if !restconf.IsNotFound(err) { + log.Printf("configure users: %v", err) + data.Error = "Could not read user 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. + log.Printf("configure users (running fallback): %v", fallErr) + data.Error = "Could not read user configuration" + } + } + const shellPath = "/ietf-system:system/authentication/user/infix-system:shell" + mgr := h.Schema.Manager() + data.Loading = mgr == nil + if mgr != nil { + data.ShellOptions = schema.OptionsFor(mgr, shellPath) + } + for _, u := range raw.System.Auth.Users { + data.Users = append(data.Users, cfgUserDisplay{ + cfgUserJSON: u, + ShellLabel: schema.StripModulePrefix(u.Shell), + KeyCount: len(u.AuthorizedKeys), + }) + } + + groups, err := h.fetchAllGroups(r.Context()) + if err != nil { + log.Printf("configure users groups: %v", err) + } + allNames := make([]string, 0, len(data.Users)) + for _, u := range data.Users { + allNames = append(allNames, u.Name) + } + for _, g := range groups { + memberSet := make(map[string]bool, len(g.UserNames)) + for _, u := range g.UserNames { + memberSet[u] = true + } + avail := make([]string, 0) + for _, u := range allNames { + if !memberSet[u] { + avail = append(avail, u) + } + } + summary := strings.Join(g.UserNames, ", ") + if summary == "" { + summary = "(none)" + } + data.Groups = append(data.Groups, cfgGroupDisplay{ + nacmGroupJSON: g, + MembersSummary: summary, + Available: avail, + }) + } + + tmplName := "configure-users.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) + } +} + +// AddUser creates a new user in the candidate datastore. +// POST /configure/users +func (h *ConfigureUsersHandler) AddUser(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("username")) + password := r.FormValue("password") + shell := r.FormValue("shell") + if name == "" { + renderSaveError(w, fmt.Errorf("username is required")) + return + } + + hash, err := HashPassword(password) + if err != nil { + log.Printf("configure users add: hash: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + user := map[string]any{ + "ietf-system:user": []map[string]any{{ + "name": name, + "password": hash, + "infix-system:shell": shell, + }}, + } + path := authPath + "/user=" + url.PathEscape(name) + if err := h.RC.Put(r.Context(), path, user); err != nil { + log.Printf("configure users add %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "User added", "/configure/users") +} + +// DeleteUser removes a user from the candidate datastore. +// DELETE /configure/users/{name} +func (h *ConfigureUsersHandler) DeleteUser(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + path := authPath + "/user=" + url.PathEscape(name) + if err := h.RC.Delete(r.Context(), path); err != nil { + log.Printf("configure users delete %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "User deleted", "/configure/users") +} + +// UpdateShell changes a user's login shell in the candidate datastore. +// POST /configure/users/{name}/shell +func (h *ConfigureUsersHandler) UpdateShell(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + shell := r.FormValue("shell") + body := map[string]any{ + "ietf-system:user": []map[string]any{{ + "name": name, + "infix-system:shell": shell, + }}, + } + path := authPath + "/user=" + url.PathEscape(name) + if err := h.RC.Patch(r.Context(), path, body); err != nil { + log.Printf("configure users shell %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Shell updated") +} + +// ChangePassword sets a new hashed password for a user in the candidate. +// POST /configure/users/{name}/password +func (h *ConfigureUsersHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + password := r.FormValue("password") + if password == "" { + renderSaveError(w, fmt.Errorf("password cannot be empty")) + return + } + + hash, err := HashPassword(password) + if err != nil { + log.Printf("configure users password %q: hash: %v", name, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + body := map[string]any{ + "ietf-system:user": []map[string]any{{ + "name": name, + "password": hash, + }}, + } + path := authPath + "/user=" + url.PathEscape(name) + if err := h.RC.Patch(r.Context(), path, body); err != nil { + log.Printf("configure users password %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Password changed") +} + +// AddKey adds an SSH authorized key for a user in the candidate. +// POST /configure/users/{name}/keys +func (h *ConfigureUsersHandler) AddKey(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + keyName := strings.TrimSpace(r.FormValue("key_name")) + keyLine := strings.TrimSpace(r.FormValue("key_data")) + + if keyName == "" || keyLine == "" { + renderSaveError(w, fmt.Errorf("key name and public key are required")) + return + } + + // Parse "algorithm base64data [comment]" from an OpenSSH public key line. + parts := strings.Fields(keyLine) + if len(parts) < 2 { + renderSaveError(w, fmt.Errorf("invalid SSH public key format")) + return + } + algorithm := parts[0] + keyBytes, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + renderSaveError(w, fmt.Errorf("invalid SSH key data: %w", err)) + return + } + + // PATCH at the system root so libyang has full ancestor-key context. + // Patching at user=admin or authorized-key leaves libyang without the + // parent list-key context and produces "List requires N keys" errors. + body := map[string]any{ + "ietf-system:system": map[string]any{ + "authentication": map[string]any{ + "user": []map[string]any{{ + "name": name, + "authorized-key": []map[string]any{{ + "name": keyName, + "algorithm": algorithm, + "key-data": keyBytes, + }}, + }}, + }, + }, + } + path := candidatePath + "/ietf-system:system" + if err := h.RC.Patch(r.Context(), path, body); err != nil { + log.Printf("configure users key add %q/%q: %v", name, keyName, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "SSH key added", "/configure/users") +} + +// DeleteKey removes an SSH authorized key for a user in the candidate. +// DELETE /configure/users/{name}/keys/{keyname} +// +// Direct DELETE to the authorized-key path fails when the key name contains +// characters like '@' that libyang interprets as module@revision syntax in path +// predicates. Work around by GET + filter + PUT at the user level instead. +func (h *ConfigureUsersHandler) DeleteKey(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + keyName := r.PathValue("keyname") + + sysPath := candidatePath + "/ietf-system:system" + var raw cfgAuthWrapper + if err := h.RC.Get(r.Context(), sysPath, &raw); err != nil { + log.Printf("configure users key delete %q/%q: GET: %v", name, keyName, err) + renderSaveError(w, err) + return + } + + var userEntry map[string]any + for _, u := range raw.System.Auth.Users { + if u.Name != name { + continue + } + filteredKeys := make([]map[string]any, 0, len(u.AuthorizedKeys)) + found := false + for _, k := range u.AuthorizedKeys { + if k.Name == keyName { + found = true + continue + } + filteredKeys = append(filteredKeys, map[string]any{ + "name": k.Name, + "algorithm": k.Algorithm, + "key-data": k.KeyData, + }) + } + if !found { + w.WriteHeader(http.StatusOK) + return + } + userEntry = map[string]any{ + "name": u.Name, + "authorized-key": filteredKeys, + } + if u.Password != "" { + userEntry["password"] = u.Password + } + if u.Shell != "" { + userEntry["infix-system:shell"] = u.Shell + } + break + } + if userEntry == nil { + w.WriteHeader(http.StatusOK) + return + } + + // PUT at the user level replaces only this user's entry (including its key + // list), which avoids the path-predicate Syntax error while not touching + // other users or system config. + putPath := authPath + "/user=" + url.PathEscape(name) + body := map[string]any{ + "ietf-system:user": []map[string]any{userEntry}, + } + if err := h.RC.Put(r.Context(), putPath, body); err != nil { + log.Printf("configure users key delete %q/%q: PUT: %v", name, keyName, err) + renderSaveError(w, err) + return + } + w.WriteHeader(http.StatusOK) +} + +// fetchAllGroups reads NACM groups from candidate, falling back to running on 404. +func (h *ConfigureUsersHandler) fetchAllGroups(ctx context.Context) ([]nacmGroupJSON, error) { + var raw nacmWrapper + if err := h.RC.Get(ctx, candidatePath+"/ietf-netconf-acm:nacm", &raw); err != nil { + if !restconf.IsNotFound(err) { + return nil, err + } + if err2 := h.RC.Get(ctx, "/data/ietf-netconf-acm:nacm", &raw); err2 != nil && !restconf.IsNotFound(err2) { + return nil, err2 + } + } + return raw.NACM.Groups.Group, nil +} + +// putGroupMembers overwrites the member list of a single NACM group. +func (h *ConfigureUsersHandler) putGroupMembers(ctx context.Context, groupName string, members []string) error { + body := map[string]any{ + "ietf-netconf-acm:group": []map[string]any{{ + "name": groupName, + "user-name": members, + }}, + } + path := nacmGroupsPath + "/group=" + url.PathEscape(groupName) + return h.RC.Put(ctx, path, body) +} + +// AddGroupMembers adds one or more users to a NACM group, moving them out of +// any previous group to maintain the one-group-per-user invariant. +// POST /configure/users/groups/{name}/members +func (h *ConfigureUsersHandler) AddGroupMembers(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + groupName := r.PathValue("name") + toAdd := r.Form["members"] + if len(toAdd) == 0 { + renderSavedRedirect(w, "No users selected", "/configure/users") + return + } + + groups, err := h.fetchAllGroups(r.Context()) + if err != nil { + renderSaveError(w, err) + return + } + + toAddSet := make(map[string]bool, len(toAdd)) + for _, u := range toAdd { + toAddSet[u] = true + } + + updates := make(map[string][]string) + var current []string + for _, g := range groups { + if g.Name == groupName { + current = g.UserNames + continue + } + changed := false + members := make([]string, 0, len(g.UserNames)) + for _, u := range g.UserNames { + if toAddSet[u] { + changed = true + continue + } + members = append(members, u) + } + if changed { + updates[g.Name] = members + } + } + + inTarget := make(map[string]bool, len(current)) + for _, u := range current { + inTarget[u] = true + } + newTarget := append([]string{}, current...) + for _, u := range toAdd { + if !inTarget[u] { + newTarget = append(newTarget, u) + } + } + updates[groupName] = newTarget + + for gName, members := range updates { + if err := h.putGroupMembers(r.Context(), gName, members); err != nil { + log.Printf("configure users groups add: write %q: %v", gName, err) + renderSaveError(w, err) + return + } + } + renderSavedRedirect(w, "Members updated", "/configure/users") +} + +// RemoveGroupMember removes a single user from a NACM group. +// DELETE /configure/users/groups/{name}/members/{user} +func (h *ConfigureUsersHandler) RemoveGroupMember(w http.ResponseWriter, r *http.Request) { + groupName := r.PathValue("name") + userName := r.PathValue("user") + + groups, err := h.fetchAllGroups(r.Context()) + if err != nil { + renderSaveError(w, err) + return + } + + newMembers := make([]string, 0) + found := false + for _, g := range groups { + if g.Name != groupName { + continue + } + for _, u := range g.UserNames { + if u == userName { + found = true + continue + } + newMembers = append(newMembers, u) + } + break + } + if !found { + w.WriteHeader(http.StatusOK) + return + } + + if err := h.putGroupMembers(r.Context(), groupName, newMembers); err != nil { + log.Printf("configure users groups remove %q/%q: %v", groupName, userName, err) + renderSaveError(w, err) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/src/webui/internal/handlers/crypt.go b/src/webui/internal/handlers/crypt.go new file mode 100644 index 00000000..430ac96a --- /dev/null +++ b/src/webui/internal/handlers/crypt.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "fmt" + "os/exec" + "strings" +) + +// HashPassword returns a yescrypt crypt hash of password using mkpasswd(1). +// mkpasswd is available on Infix target systems and uses the system's libcrypt, +// so the output matches whatever the device expects (default: yescrypt $y$). +func HashPassword(password string) (string, error) { + path, err := exec.LookPath("mkpasswd") + if err != nil { + return "", fmt.Errorf("mkpasswd not found: %w", err) + } + cmd := exec.Command(path, "--method=yescrypt", "--password-fd=0") + cmd.Stdin = strings.NewReader(password) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("mkpasswd: %w", err) + } + return strings.TrimSpace(string(out)), nil +} diff --git a/src/webui/internal/handlers/nacm.go b/src/webui/internal/handlers/nacm.go new file mode 100644 index 00000000..a64bc635 --- /dev/null +++ b/src/webui/internal/handlers/nacm.go @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "html/template" + "log" + "net/http" + "strings" + + "github.com/kernelkit/webui/internal/restconf" +) + +// ─── RESTCONF JSON types ────────────────────────────────────────────────────── + +type nacmWrapper struct { + NACM nacmJSON `json:"ietf-netconf-acm:nacm"` +} + +type nacmJSON struct { + EnableNACM bool `json:"enable-nacm"` + ReadDefault string `json:"read-default"` + WriteDefault string `json:"write-default"` + ExecDefault string `json:"exec-default"` + DeniedOperations uint32 `json:"denied-operations"` + DeniedDataWrites uint32 `json:"denied-data-writes"` + DeniedNotifications uint32 `json:"denied-notifications"` + Groups nacmGroupsJSON `json:"groups"` + RuleList []nacmRuleListJSON `json:"rule-list"` +} + +type nacmGroupsJSON struct { + Group []nacmGroupJSON `json:"group"` +} + +type nacmGroupJSON struct { + Name string `json:"name"` + UserNames []string `json:"user-name"` +} + +type nacmRuleListJSON struct { + Name string `json:"name"` + Group []string `json:"group"` + Rule []nacmRuleJSON `json:"rule"` +} + +type nacmRuleJSON struct { + Name string `json:"name"` + ModuleName string `json:"module-name"` + Path string `json:"path"` + AccessOperations string `json:"access-operations"` + Action string `json:"action"` +} + +type nacmAuthWrapper struct { + System struct { + Authentication struct { + User []nacmUserJSON `json:"user"` + } `json:"authentication"` + } `json:"ietf-system:system"` +} + +type nacmUserJSON struct { + Name string `json:"name"` + Password string `json:"password"` + Shell string `json:"infix-system:shell"` + AuthorizedKey []interface{} `json:"authorized-key"` +} + +// ─── Template data ──────────────────────────────────────────────────────────── + +type nacmPageData struct { + PageData + Error string + + // Summary card + Enabled string + ReadDefault string + WriteDefault string + ExecDefault string + DeniedOperations uint32 + DeniedDataWrites uint32 + DeniedNotifications uint32 + + // Permission matrix + Matrix []nacmGroupPerm + + // Users and Groups tables + Users []nacmUserEntry + Groups []nacmGroupEntry +} + +type nacmGroupPerm struct { + Name string + Read nacmCell + Write nacmCell + Exec nacmCell + Restrictions []string +} + +type nacmCell struct { + Class string // "nacm-full" | "nacm-restricted" | "nacm-denied" + Symbol string // "✓" | "⚠" | "✗" +} + +type nacmUserEntry struct { + Name string + Shell string + Login string +} + +type nacmGroupEntry struct { + Name string + Members string +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// NACMHandler serves the NACM page. +type NACMHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the NACM page (GET /nacm). +func (h *NACMHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := nacmPageData{ + PageData: newPageData(r, "nacm", "NACM"), + } + + var nacmRaw nacmWrapper + nacmErr := h.RC.Get(r.Context(), "/data/ietf-netconf-acm:nacm", &nacmRaw) + + var authRaw nacmAuthWrapper + authErr := h.RC.Get(r.Context(), "/data/ietf-system:system/authentication", &authRaw) + + if nacmErr != nil { + log.Printf("restconf nacm: %v", nacmErr) + data.Error = "Could not fetch NACM data" + } else { + n := nacmRaw.NACM + if n.ReadDefault == "" { + n.ReadDefault = "permit" + } + if n.WriteDefault == "" { + n.WriteDefault = "deny" + } + if n.ExecDefault == "" { + n.ExecDefault = "permit" + } + + if n.EnableNACM { + data.Enabled = "yes" + } else { + data.Enabled = "no" + } + data.ReadDefault = n.ReadDefault + data.WriteDefault = n.WriteDefault + data.ExecDefault = n.ExecDefault + data.DeniedOperations = n.DeniedOperations + data.DeniedDataWrites = n.DeniedDataWrites + data.DeniedNotifications = n.DeniedNotifications + + data.Matrix = analyzeNACMPermissions(n) + + for _, g := range n.Groups.Group { + data.Groups = append(data.Groups, nacmGroupEntry{ + Name: g.Name, + Members: strings.Join(g.UserNames, " "), + }) + } + } + + if authErr != nil { + log.Printf("restconf nacm auth: %v", authErr) + } else { + for _, u := range authRaw.System.Authentication.User { + data.Users = append(data.Users, buildNACMUserEntry(u)) + } + } + + tmplName := "nacm.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) + } +} + +// ─── Permission matrix logic ────────────────────────────────────────────────── +// Mirrors cli_pretty._analyze_group_permissions exactly. + +func analyzeNACMPermissions(n nacmJSON) []nacmGroupPerm { + readDefault := n.ReadDefault == "permit" + writeDefault := n.WriteDefault == "permit" + execDefault := n.ExecDefault == "permit" + + // Collect deny rules that apply to "*" (all groups) — these become restrictions. + var globalDenials []nacmRuleJSON + for _, rl := range n.RuleList { + for _, g := range rl.Group { + if g == "*" { + for _, rule := range rl.Rule { + if rule.Action == "deny" { + globalDenials = append(globalDenials, rule) + } + } + break + } + } + } + + var results []nacmGroupPerm + + for _, group := range n.Groups.Group { + canRead := readDefault + canWrite := writeDefault + canExec := execDefault + hasPermitAll := false + var restrictions []string + + // Process rule-lists in order; only those that name this group specifically. + for _, rl := range n.RuleList { + applies := false + for _, g := range rl.Group { + if g == group.Name { + applies = true + break + } + } + if !applies { + continue + } + + for _, rule := range rl.Rule { + action := rule.Action + accessOps := strings.ToLower(rule.AccessOperations) + moduleName := rule.ModuleName + + // permit-all: module-name="*" AND access-operations="*" + if action == "permit" && moduleName == "*" && accessOps == "*" { + hasPermitAll = true + break + } + + // Explicit deny for write/exec operations + if action == "deny" && moduleName == "*" { + if strings.Contains(accessOps, "create") && + strings.Contains(accessOps, "update") && + strings.Contains(accessOps, "delete") { + canWrite = false + } + if strings.Contains(accessOps, "exec") { + canExec = false + } + } + } + + if hasPermitAll { + break + } + } + + // permit-all overrides everything, including unfavourable global defaults. + if hasPermitAll { + canRead = true + canWrite = true + canExec = true + } + + // Global denials create restrictions for groups that don't have permit-all. + if !hasPermitAll { + seen := map[string]bool{} + for _, rule := range globalDenials { + var restriction string + if rule.Path != "" { + parts := strings.Split(strings.TrimRight(rule.Path, "/"), "/") + restriction = parts[len(parts)-1] + } else if rule.ModuleName != "" { + restriction = strings.TrimPrefix(rule.ModuleName, "ietf-") + } + if restriction != "" && !seen[restriction] { + seen[restriction] = true + restrictions = append(restrictions, restriction) + } + } + } + + results = append(results, nacmGroupPerm{ + Name: group.Name, + Read: makeNACMCell(canRead, len(restrictions) > 0), + Write: makeNACMCell(canWrite, len(restrictions) > 0), + Exec: makeNACMCell(canExec, len(restrictions) > 0), + Restrictions: restrictions, + }) + } + + return results +} + +func makeNACMCell(hasAccess, hasRestrictions bool) nacmCell { + if !hasAccess { + return nacmCell{Class: "nacm-denied", Symbol: "✗"} + } + if hasRestrictions { + return nacmCell{Class: "nacm-restricted", Symbol: "⚠"} + } + return nacmCell{Class: "nacm-full", Symbol: "✓"} +} + +// ─── User entry helper ──────────────────────────────────────────────────────── + +func buildNACMUserEntry(u nacmUserJSON) nacmUserEntry { + shell := u.Shell + if idx := strings.LastIndex(shell, ":"); idx >= 0 { + shell = shell[idx+1:] + } + if shell == "" || shell == "false" { + shell = "-" + } + + var login string + hasPassword := u.Password != "" + hasKeys := len(u.AuthorizedKey) > 0 + switch { + case hasPassword && hasKeys: + login = "password+key" + case hasPassword: + login = "password" + case hasKeys: + login = "key" + default: + login = "-" + } + + return nacmUserEntry{ + Name: u.Name, + Shell: shell, + Login: login, + } +} diff --git a/src/webui/internal/restconf/client.go b/src/webui/internal/restconf/client.go index 4c8e45b3..5b2c8052 100644 --- a/src/webui/internal/restconf/client.go +++ b/src/webui/internal/restconf/client.go @@ -59,74 +59,117 @@ func NewClient(baseURL string, insecureTLS bool) *Client { } } +// doRequest builds and executes an HTTP request against the RESTCONF server, +// setting Accept and Basic Auth from the context. The caller must close Body. +func (c *Client) doRequest(ctx context.Context, method, path string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/yang-data+json") + creds := CredentialsFromContext(ctx) + req.SetBasicAuth(creds.Username, creds.Password) + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("restconf request failed: %w", err) + } + return resp, nil +} + // Get fetches a RESTCONF resource, decoding the JSON response into target. // User credentials are taken from the request context (set by auth middleware). func (c *Client) Get(ctx context.Context, path string, target any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + resp, err := c.doRequest(ctx, http.MethodGet, path) if err != nil { return err } - - req.Header.Set("Accept", "application/yang-data+json") - - creds := CredentialsFromContext(ctx) - req.SetBasicAuth(creds.Username, creds.Password) - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("restconf request failed: %w", err) - } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { return parseError(resp) } - return json.NewDecoder(resp.Body).Decode(target) } // Post sends a POST request to a RESTCONF RPC endpoint. // Used for operations like system-restart that return no body. func (c *Client) Post(ctx context.Context, path string) error { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, nil) + resp, err := c.doRequest(ctx, http.MethodPost, path) if err != nil { return err } - - req.Header.Set("Accept", "application/yang-data+json") - - creds := CredentialsFromContext(ctx) - req.SetBasicAuth(creds.Username, creds.Password) - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("restconf request failed: %w", err) - } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { return parseError(resp) } - return nil } // PostJSON sends a POST request with a JSON body to a RESTCONF RPC endpoint. // Used for RPCs that require input parameters (e.g. install-bundle). func (c *Client) PostJSON(ctx context.Context, path string, body any) error { + return c.writeJSON(ctx, http.MethodPost, path, body) +} + +// Put replaces a RESTCONF config resource with the given value. +func (c *Client) Put(ctx context.Context, path string, body any) error { + return c.writeJSON(ctx, http.MethodPut, path, body) +} + +// Patch merges the given value into a RESTCONF config resource. +func (c *Client) Patch(ctx context.Context, path string, body any) error { + return c.writeJSON(ctx, http.MethodPatch, path, body) +} + +// Delete removes a RESTCONF config resource. +func (c *Client) Delete(ctx context.Context, path string) error { + resp, err := c.doRequest(ctx, http.MethodDelete, path) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return parseError(resp) + } + return nil +} + +// GetDatastore fetches the full contents of the named NMDA datastore as raw JSON. +// datastore is one of "running", "candidate", "operational", "startup", "factory-default". +func (c *Client) GetDatastore(ctx context.Context, datastore string) (json.RawMessage, error) { + return c.GetRaw(ctx, "/ds/ietf-datastores:"+datastore) +} + +// PutDatastore replaces the named NMDA datastore with the given raw JSON. +func (c *Client) PutDatastore(ctx context.Context, datastore string, body json.RawMessage) error { + return c.writeJSON(ctx, http.MethodPut, "/ds/ietf-datastores:"+datastore, body) +} + +// CopyDatastore copies the full contents of src into dst. +// Mirrors the Infamy test framework's copy() operation. +func (c *Client) CopyDatastore(ctx context.Context, src, dst string) error { + data, err := c.GetDatastore(ctx, src) + if err != nil { + return fmt.Errorf("copy datastore: read %s: %w", src, err) + } + if err := c.PutDatastore(ctx, dst, data); err != nil { + return fmt.Errorf("copy datastore: write %s: %w", dst, err) + } + return nil +} + +// writeJSON encodes body as JSON and sends it with the given HTTP method. +func (c *Client) writeJSON(ctx context.Context, method, path string, body any) error { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(body); err != nil { return fmt.Errorf("encoding request body: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, &buf) + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, &buf) if err != nil { return err } - req.Header.Set("Content-Type", "application/yang-data+json") req.Header.Set("Accept", "application/yang-data+json") - creds := CredentialsFromContext(ctx) req.SetBasicAuth(creds.Username, creds.Password) @@ -136,35 +179,24 @@ func (c *Client) PostJSON(ctx context.Context, path string, body any) error { } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + if resp.StatusCode != http.StatusOK && + resp.StatusCode != http.StatusCreated && + resp.StatusCode != http.StatusNoContent { return parseError(resp) } - return nil } // GetRaw fetches a RESTCONF resource and returns the raw JSON bytes. func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + resp, err := c.doRequest(ctx, http.MethodGet, path) if err != nil { return nil, err } - - req.Header.Set("Accept", "application/yang-data+json") - - creds := CredentialsFromContext(ctx) - req.SetBasicAuth(creds.Username, creds.Password) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("restconf request failed: %w", err) - } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { return nil, parseError(resp) } - return io.ReadAll(resp.Body) } diff --git a/src/webui/internal/restconf/fetcher.go b/src/webui/internal/restconf/fetcher.go index a06f57a6..ff415bd4 100644 --- a/src/webui/internal/restconf/fetcher.go +++ b/src/webui/internal/restconf/fetcher.go @@ -2,7 +2,10 @@ package restconf -import "context" +import ( + "context" + "encoding/json" +) // Fetcher is the RESTCONF client interface used by handlers. // *Client satisfies this interface; testutil.MockFetcher provides a test double. @@ -11,6 +14,14 @@ type Fetcher interface { GetRaw(ctx context.Context, path string) ([]byte, error) Post(ctx context.Context, path string) error PostJSON(ctx context.Context, path string, body any) error + + Put(ctx context.Context, path string, body any) error + Patch(ctx context.Context, path string, body any) error + Delete(ctx context.Context, path string) error + + GetDatastore(ctx context.Context, datastore string) (json.RawMessage, error) + PutDatastore(ctx context.Context, datastore string, body json.RawMessage) error + CopyDatastore(ctx context.Context, src, dst string) error } var _ Fetcher = (*Client)(nil) diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 987919c5..cb71d08a 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -81,6 +81,10 @@ func New( if err != nil { return nil, err } + nacmTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/nacm.html") + if err != nil { + return nil, err + } servicesTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/services.html") if err != nil { return nil, err @@ -89,6 +93,14 @@ func New( if err != nil { return nil, err } + cfgSysTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-system.html") + if err != nil { + return nil, err + } + cfgUsersTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-users.html") + if err != nil { + return nil, err + } login := &auth.LoginHandler{ Store: store, @@ -130,8 +142,12 @@ func New( ntp := &handlers.NTPHandler{Template: ntpTmpl, RC: rc} lldp := &handlers.LLDPHandler{Template: lldpTmpl, RC: rc} mdns := &handlers.MDNSHandler{Template: mdnsTmpl, RC: rc} + nacm := &handlers.NACMHandler{Template: nacmTmpl, RC: rc} services := &handlers.ServicesHandler{Template: servicesTmpl, RC: rc} containers := &handlers.ContainersHandler{Template: containersTmpl, RC: rc} + cfg := &handlers.ConfigureHandler{RC: rc} + cfgSys := &handlers.ConfigureSystemHandler{Template: cfgSysTmpl, RC: rc} + cfgUsers := &handlers.ConfigureUsersHandler{Template: cfgUsersTmpl, RC: rc} mux := http.NewServeMux() @@ -164,9 +180,31 @@ func New( mux.HandleFunc("GET /ntp", ntp.Overview) mux.HandleFunc("GET /lldp", lldp.Overview) mux.HandleFunc("GET /mdns", mdns.Overview) + mux.HandleFunc("GET /nacm", nacm.Overview) mux.HandleFunc("GET /services", services.Overview) mux.HandleFunc("GET /containers", containers.Overview) + // Configure routes. + mux.HandleFunc("POST /configure/enter", cfg.Enter) + mux.HandleFunc("POST /configure/apply", cfg.Apply) + mux.HandleFunc("POST /configure/apply-and-save", cfg.ApplyAndSave) + mux.HandleFunc("POST /configure/abort", cfg.Abort) + mux.HandleFunc("GET /configure/system", cfgSys.Overview) + 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/preferences", cfgSys.SavePreferences) + mux.HandleFunc("GET /configure/users", cfgUsers.Overview) + mux.HandleFunc("POST /configure/users", cfgUsers.AddUser) + mux.HandleFunc("DELETE /configure/users/{name}", cfgUsers.DeleteUser) + mux.HandleFunc("POST /configure/users/{name}/shell", cfgUsers.UpdateShell) + mux.HandleFunc("POST /configure/users/{name}/password", cfgUsers.ChangePassword) + mux.HandleFunc("POST /configure/users/{name}/keys", cfgUsers.AddKey) + mux.HandleFunc("DELETE /configure/users/{name}/keys/{keyname}", cfgUsers.DeleteKey) + mux.HandleFunc("POST /configure/users/groups/{name}/members", cfgUsers.AddGroupMembers) + mux.HandleFunc("DELETE /configure/users/groups/{name}/members/{user}", cfgUsers.RemoveGroupMember) + handler := authMiddleware(store, mux) handler = csrfMiddleware(handler) handler = securityHeadersMiddleware(handler) diff --git a/src/webui/internal/testutil/helpers.go b/src/webui/internal/testutil/helpers.go index 79f9d407..516e39da 100644 --- a/src/webui/internal/testutil/helpers.go +++ b/src/webui/internal/testutil/helpers.go @@ -93,3 +93,17 @@ func (m *MockFetcher) PostJSON(_ context.Context, path string, _ any) error { } return nil } + +func (m *MockFetcher) Put(_ context.Context, _ string, _ any) error { return nil } +func (m *MockFetcher) Patch(_ context.Context, _ string, _ any) error { return nil } +func (m *MockFetcher) Delete(_ context.Context, _ string) error { return nil } + +func (m *MockFetcher) GetDatastore(_ context.Context, _ string) (json.RawMessage, error) { + return json.RawMessage("{}"), nil +} + +func (m *MockFetcher) PutDatastore(_ context.Context, _ string, _ json.RawMessage) error { + return nil +} + +func (m *MockFetcher) CopyDatastore(_ context.Context, _, _ string) error { return nil } diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 6099fc12..08098e9b 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -1159,6 +1159,45 @@ details[open].nav-group-top > summary.nav-group-summary-top::before { .light .svc-stopped { color: #ca8a04; } .light .svc-error { color: #dc2626; } +/* ─── NACM matrix ─────────────────────────────────────────────────────────── */ +.nacm-matrix .nacm-col { text-align: center; width: 5rem; } +.nacm-group { font-family: var(--font-mono); font-size: 0.85rem; } +.nacm-cell { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 4px; + font-size: 1rem; + font-weight: 700; +} +.nacm-full { background: rgba(34, 197, 94, 0.2); color: #16a34a; } +.nacm-restricted { background: rgba(234, 179, 8, 0.2); color: #854d0e; } +.nacm-denied { background: rgba(239, 68, 68, 0.2); color: #dc2626; } +.nacm-restrictions-row td { padding-top: 0; } +.nacm-restrictions { font-size: 0.75rem; color: var(--fg-muted); font-style: italic; } +.nacm-legend { + padding: 0.5rem 1rem 0.75rem; + font-size: 0.8rem; + color: var(--fg-muted); + display: flex; + align-items: center; + gap: 0.35rem; +} +.nacm-legend .nacm-cell { width: 1.4rem; height: 1.4rem; font-size: 0.8rem; } +@media (prefers-color-scheme: dark) { + .nacm-full { background: rgba(34, 197, 94, 0.2); color: #86efac; } + .nacm-restricted { background: rgba(234, 179, 8, 0.2); color: #fde047; } + .nacm-denied { background: rgba(239, 68, 68, 0.2); color: #fca5a5; } +} +.dark .nacm-full { background: rgba(34, 197, 94, 0.2); color: #86efac; } +.dark .nacm-restricted { background: rgba(234, 179, 8, 0.2); color: #fde047; } +.dark .nacm-denied { background: rgba(239, 68, 68, 0.2); color: #fca5a5; } +.light .nacm-full { background: rgba(34, 197, 94, 0.15); color: #16a34a; } +.light .nacm-restricted { background: rgba(234, 179, 8, 0.15); color: #854d0e; } +.light .nacm-denied { background: rgba(239, 68, 68, 0.15); color: #dc2626; } + /* Sub-heading within a card body (e.g. "Active Leases" in DHCP) */ .section-subtitle { font-size: 0.75rem; @@ -1875,3 +1914,157 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); } @media (max-width: 480px) { .col-priority-3 { display: none; } } + +/* ─── Confirm dialog ──────────────────────────────────────────────────────── */ +#confirm-dialog { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + color: var(--fg); + padding: 1.5rem 2rem 1.25rem; + min-width: 20rem; + max-width: 90vw; + box-shadow: var(--shadow); + /* Centre in the viewport regardless of scroll position */ + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; +} +#confirm-dialog::backdrop { + background: rgba(0, 0, 0, 0.45); +} +.dialog-message { + font-size: 0.95rem; + margin: 0 0 1.25rem; + line-height: 1.5; +} +.dialog-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; +} +.btn-outline { + background: transparent; + color: var(--fg); + border: 1px solid var(--border); +} +.btn-outline:hover { background: var(--border); } +.btn-danger-outline { + background: transparent; + color: var(--danger); + border: 1px solid var(--danger); +} +.btn-danger-outline:hover { background: rgba(220, 38, 38, 0.08); } + +.btn-accept { + background: var(--success); + color: #fff; + border: 1px solid transparent; +} +.btn-accept:hover { background: var(--green-600); } + +/* ─── Configure toolbar ───────────────────────────────────────────────────── */ +.configure-toolbar { + position: fixed; + bottom: 0; + left: var(--sidebar-width, 220px); + right: 0; + z-index: 200; + background: var(--surface); + border-top: 1px solid var(--border); + padding: 0.75rem 1.5rem; + box-shadow: 0 -2px 8px rgba(0,0,0,0.08); +} +.configure-toolbar-inner { + display: flex; + gap: 0.75rem; + max-width: 960px; +} +/* Push page content above the toolbar */ +.configure-page { padding-bottom: 4rem; } + +/* ─── Editable form inputs inside cards ───────────────────────────────────── */ +.cfg-input { + width: 100%; + padding: 0.4rem 0.6rem; + background: var(--surface); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.875rem; + font-family: inherit; + transition: border-color 0.15s, box-shadow 0.15s; + box-sizing: border-box; +} +.cfg-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} +.cfg-input-sm { max-width: 8rem; } +.cfg-input-mono { font-family: var(--font-mono); font-size: 0.85rem; } + +/* Editable table rows */ +.cfg-table td { padding: 0.45rem 0.75rem; vertical-align: middle; } +.cfg-table td:last-child { width: 3rem; text-align: center; } +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border: none; + border-radius: var(--radius); + background: transparent; + color: var(--fg-muted); + cursor: pointer; + transition: background 0.1s, color 0.1s; + padding: 0; +} +.btn-icon:hover { background: var(--border); color: var(--fg); } +.btn-icon-danger:hover { color: var(--danger); background: rgba(220,38,38,0.08); } +.btn-add-row { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: var(--primary); + background: none; + border: none; + cursor: pointer; + padding: 0.5rem 0.75rem; +} +.btn-add-row:hover { text-decoration: underline; } + +/* Inline forms inside users table */ +.user-shell-form, .user-add-inline { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} +.user-shell-form .cfg-input { flex: 1; min-width: 0; } +.user-add-inline .cfg-input { width: auto; flex: 1; min-width: 8rem; } + +/* Card-level save feedback */ +.cfg-card-footer { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + border-top: 1px solid var(--border); +} +.cfg-save-status { + font-size: 0.8rem; + color: var(--fg-muted); + min-height: 1.2em; +} +.cfg-save-status.saved { color: #16a34a; } +.cfg-save-error { font-size: 0.8rem; color: var(--danger); } +@media (prefers-color-scheme: dark) { + .cfg-save-status.saved { color: #86efac; } +} +.dark .cfg-save-status.saved { color: #86efac; } +.light .cfg-save-status.saved { color: #16a34a; } diff --git a/src/webui/static/img/icons/nacm.svg b/src/webui/static/img/icons/nacm.svg new file mode 100644 index 00000000..c8a9b0cf --- /dev/null +++ b/src/webui/static/img/icons/nacm.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/webui/static/img/icons/system.svg b/src/webui/static/img/icons/system.svg new file mode 100644 index 00000000..6f08138c --- /dev/null +++ b/src/webui/static/img/icons/system.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/webui/templates/fragments/configure-toolbar.html b/src/webui/templates/fragments/configure-toolbar.html new file mode 100644 index 00000000..2fe429a6 --- /dev/null +++ b/src/webui/templates/fragments/configure-toolbar.html @@ -0,0 +1,24 @@ +{{define "configure-toolbar"}} +
+
+ + + +
+
+{{end}} diff --git a/src/webui/templates/layouts/base.html b/src/webui/templates/layouts/base.html index 3fb0cab7..2fc691c6 100644 --- a/src/webui/templates/layouts/base.html +++ b/src/webui/templates/layouts/base.html @@ -73,6 +73,13 @@ + +

+
+ + +
+
{{end}} diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index 38046346..640d38ea 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -167,8 +167,31 @@ -