webui: add Configure mode with System, Users, and NACM 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, NACM group membership)
- Status > NACM page: read-only group permission matrix derived from
  ietf-netconf-acm groups + rule-list mappings
- 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>
This commit is contained in:
Joachim Wiberg
2026-06-14 22:07:36 +02:00
parent 8b10678dee
commit 2ef7490f69
18 changed files with 2518 additions and 46 deletions
+67
View File
@@ -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)
}
@@ -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(`<span class="cfg-save-error">` + template.HTMLEscapeString(msg) + `</span>`))
}
@@ -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)
}
+26
View File
@@ -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
}
+343
View File
@@ -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,
}
}
+76 -44
View File
@@ -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)
}
+12 -1
View File
@@ -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)
+38
View File
@@ -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)
+14
View File
@@ -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 }
+193
View File
@@ -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; }
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>

After

Width:  |  Height:  |  Size: 358 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
<path d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>

After

Width:  |  Height:  |  Size: 428 B

@@ -0,0 +1,24 @@
{{define "configure-toolbar"}}
<div class="configure-toolbar">
<div class="configure-toolbar-inner">
<button type="button" class="btn btn-primary cfg-apply-btn"
hx-post="/configure/apply"
hx-swap="none"
hx-confirm="Apply staged changes to running configuration? Changes will be lost on reboot unless saved.">
Apply
</button>
<button type="button" class="btn btn-accept cfg-apply-save-btn"
hx-post="/configure/apply-and-save"
hx-swap="none"
hx-confirm="Apply staged changes and save to startup configuration?">
Apply &amp; Save
</button>
<button type="button" class="btn btn-danger-outline cfg-abort-btn"
hx-post="/configure/abort"
hx-swap="none"
hx-confirm="Discard all staged changes and return to Status?">
Abort
</button>
</div>
</div>
{{end}}
+7
View File
@@ -73,6 +73,13 @@
</main>
</div>
</div>
<dialog id="confirm-dialog" aria-modal="true">
<p class="dialog-message" id="dialog-message"></p>
<div class="dialog-actions">
<button class="btn btn-primary" id="dialog-confirm" autofocus>Confirm</button>
<button class="btn btn-outline" id="dialog-cancel">Cancel</button>
</div>
</dialog>
</body>
</html>
{{end}}
+24 -1
View File
@@ -167,8 +167,31 @@
</details>
<details class="nav-group-top">
<details class="nav-group-top" id="nav-configure"
{{if or (eq .ActivePage "configure-system") (eq .ActivePage "configure-users")}}open{{end}}>
<summary class="nav-group-summary-top">Configure</summary>
<ul class="nav-group-items">
<li>
<a href="/configure/system"
hx-get="/configure/system"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "configure-system"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/system.svg')"></span>
System
</a>
</li>
<li>
<a href="/configure/users"
hx-get="/configure/users"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "configure-users"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/nacm.svg')"></span>
Users
</a>
</li>
</ul>
</details>
<details class="nav-group-top">
@@ -0,0 +1,423 @@
{{define "configure-system.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
<div class="configure-page">
{{if .Error}}
<div class="alert alert-error">{{.Error}}</div>
{{end}}
<section class="info-grid">
{{/* ── Identity ─────────────────────────────────────────────────────── */}}
<form class="info-card"
hx-post="/configure/system/identity"
hx-swap="none">
<div class="card-header">Identity</div>
<table class="info-table">
<tr>
<th>Hostname</th>
<td><input class="cfg-input" type="text" name="hostname" value="{{.Hostname}}" required></td>
<td class="cfg-reset-col"></td>
</tr>
<tr>
<th>Contact</th>
<td><input class="cfg-input" type="text" name="contact" value="{{.Contact}}"></td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path=/ietf-system:system/contact&redirect=/configure/system"
hx-swap="none"
hx-confirm="Reset contact to its YANG default?"></button>
</td>
</tr>
<tr>
<th>Location</th>
<td><input class="cfg-input" type="text" name="location" value="{{.Location}}"></td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path=/ietf-system:system/location&redirect=/configure/system"
hx-swap="none"
hx-confirm="Reset location to its YANG default?"></button>
</td>
</tr>
</table>
<div class="cfg-card-footer">
<button class="btn btn-primary" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
{{/* ── Clock ───────────────────────────────────────────────────────── */}}
<form class="info-card"
hx-post="/configure/system/clock"
hx-swap="none">
<div class="card-header">Clock</div>
<table class="info-table">
<tr>
<th>Timezone</th>
<td>
<input class="cfg-input" type="text" name="timezone" value="{{.Timezone}}"
list="tz-list" placeholder="e.g. Europe/Stockholm" autocomplete="off">
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path=/ietf-system:system/clock/timezone-name&redirect=/configure/system"
hx-swap="none"
hx-confirm="Reset timezone to its YANG default?"></button>
</td>
</tr>
</table>
<div class="cfg-card-footer">
<button class="btn btn-primary" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
{{/* ── NTP servers ──────────────────────────────────────────────────── */}}
<form class="info-card info-grid-span-2"
hx-put="/configure/system/ntp"
hx-swap="none">
<div class="card-header">NTP Servers</div>
<div class="data-table-wrap">
<table class="data-table cfg-table" id="ntp-table">
<thead>
<tr>
<th>Name / Key</th>
<th>Address</th>
<th class="cfg-input-sm">Port</th>
<th>Prefer</th>
<th></th>
</tr>
</thead>
<tbody id="ntp-tbody">
{{range $i, $s := .NTP.Servers}}
<tr>
<td><input class="cfg-input" type="text"
name="ntp_name_{{$i}}" value="{{$s.Name}}" required></td>
<td><input class="cfg-input" type="text"
name="ntp_addr_{{$i}}" value="{{$s.UDP.Address}}"></td>
<td><input class="cfg-input cfg-input-sm" type="number" min="1" max="65535"
name="ntp_port_{{$i}}" value="{{if $s.UDP.Port}}{{$s.UDP.Port}}{{end}}"
placeholder="123"></td>
<td style="text-align:center">
<input type="checkbox" name="ntp_prefer_{{$i}}" {{if $s.Prefer}}checked{{end}}>
</td>
<td>
<button type="button" class="btn-icon btn-icon-danger cfg-delete-row"
title="Remove">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="cfg-card-footer">
<button type="button" class="btn-add-row" data-table="ntp-tbody"
data-template="ntp">+ Add server</button>
<button class="btn btn-primary" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
{{/* ── DNS ─────────────────────────────────────────────────────────── */}}
<form class="info-card info-grid-span-2"
hx-put="/configure/system/dns"
hx-swap="none">
<div class="card-header">DNS</div>
<div class="section-subtitle">Search domains</div>
<div class="data-table-wrap">
<table class="data-table cfg-table" id="dns-search-table">
<thead>
<tr><th>Domain</th><th></th></tr>
</thead>
<tbody id="dns-search-tbody">
{{range $i, $d := .DNS.Search}}
<tr>
<td><input class="cfg-input" type="text"
name="dns_search_{{$i}}" value="{{$d}}"></td>
<td>
<button type="button" class="btn-icon btn-icon-danger cfg-delete-row" title="Remove">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div style="padding: 0 0.75rem;">
<button type="button" class="btn-add-row" data-table="dns-search-tbody"
data-template="dns-search">+ Add domain</button>
</div>
<div class="section-subtitle" style="margin-top:0.75rem">Servers</div>
<div class="data-table-wrap">
<table class="data-table cfg-table" id="dns-server-table">
<thead>
<tr>
<th>Name / Key</th>
<th>Address</th>
<th class="cfg-input-sm">Port</th>
<th></th>
</tr>
</thead>
<tbody id="dns-server-tbody">
{{range $i, $s := .DNS.Servers}}
<tr>
<td><input class="cfg-input" type="text"
name="dns_name_{{$i}}" value="{{$s.Name}}" required></td>
<td><input class="cfg-input" type="text"
name="dns_addr_{{$i}}" value="{{$s.UDPAndTCP.Address}}"></td>
<td><input class="cfg-input cfg-input-sm" type="number" min="1" max="65535"
name="dns_port_{{$i}}" value="{{if $s.UDPAndTCP.Port}}{{$s.UDPAndTCP.Port}}{{end}}"
placeholder="53"></td>
<td>
<button type="button" class="btn-icon btn-icon-danger cfg-delete-row" title="Remove">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="cfg-card-footer">
<button type="button" class="btn-add-row" data-table="dns-server-tbody"
data-template="dns-server">+ Add server</button>
<button class="btn btn-primary" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
{{/* ── Preferences ─────────────────────────────────────────────────── */}}
<form class="info-card info-grid-span-2"
hx-post="/configure/system/preferences"
hx-swap="none">
<div class="card-header">Preferences</div>
<table class="info-table">
<tr>
<th>Text editor</th>
<td>
<select class="cfg-input" name="text_editor">
<option value="">— not set —</option>
<option value="infix-system:emacs" {{if eq .TextEditor "infix-system:emacs" }}selected{{end}}>Emacs</option>
<option value="infix-system:nano" {{if eq .TextEditor "infix-system:nano" }}selected{{end}}>Nano</option>
<option value="infix-system:vi" {{if eq .TextEditor "infix-system:vi" }}selected{{end}}>Vi</option>
</select>
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path=/ietf-system:system/infix-system:text-editor&redirect=/configure/system"
hx-swap="none"
hx-confirm="Reset text editor to its YANG default?"></button>
</td>
</tr>
<tr>
<th>Login banner</th>
<td>
<textarea class="cfg-input cfg-input-mono" name="motd_banner" rows="4"
placeholder="Displayed after SSH/console login…">{{.MotdBanner}}</textarea>
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path=/ietf-system:system/infix-system:motd-banner&redirect=/configure/system"
hx-swap="none"
hx-confirm="Reset login banner to its YANG default?"></button>
</td>
</tr>
</table>
<div class="cfg-card-footer">
<button class="btn btn-primary" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
</section>
{{/* ── Timezone autocomplete datalist ──────────────────────────────────── */}}
<datalist id="tz-list">
<option>Africa/Abidjan</option><option>Africa/Accra</option><option>Africa/Addis_Ababa</option>
<option>Africa/Algiers</option><option>Africa/Asmara</option><option>Africa/Bamako</option>
<option>Africa/Bangui</option><option>Africa/Banjul</option><option>Africa/Bissau</option>
<option>Africa/Blantyre</option><option>Africa/Brazzaville</option><option>Africa/Bujumbura</option>
<option>Africa/Cairo</option><option>Africa/Casablanca</option><option>Africa/Ceuta</option>
<option>Africa/Conakry</option><option>Africa/Dakar</option><option>Africa/Dar_es_Salaam</option>
<option>Africa/Djibouti</option><option>Africa/Douala</option><option>Africa/El_Aaiun</option>
<option>Africa/Freetown</option><option>Africa/Gaborone</option><option>Africa/Harare</option>
<option>Africa/Johannesburg</option><option>Africa/Juba</option><option>Africa/Kampala</option>
<option>Africa/Khartoum</option><option>Africa/Kigali</option><option>Africa/Kinshasa</option>
<option>Africa/Lagos</option><option>Africa/Libreville</option><option>Africa/Lome</option>
<option>Africa/Luanda</option><option>Africa/Lubumbashi</option><option>Africa/Lusaka</option>
<option>Africa/Malabo</option><option>Africa/Maputo</option><option>Africa/Maseru</option>
<option>Africa/Mbabane</option><option>Africa/Mogadishu</option><option>Africa/Monrovia</option>
<option>Africa/Nairobi</option><option>Africa/Ndjamena</option><option>Africa/Niamey</option>
<option>Africa/Nouakchott</option><option>Africa/Ouagadougou</option><option>Africa/Porto-Novo</option>
<option>Africa/Sao_Tome</option><option>Africa/Tripoli</option><option>Africa/Tunis</option>
<option>Africa/Windhoek</option>
<option>America/Adak</option><option>America/Anchorage</option><option>America/Anguilla</option>
<option>America/Antigua</option><option>America/Araguaina</option>
<option>America/Argentina/Buenos_Aires</option><option>America/Argentina/Catamarca</option>
<option>America/Argentina/Cordoba</option><option>America/Argentina/Jujuy</option>
<option>America/Argentina/La_Rioja</option><option>America/Argentina/Mendoza</option>
<option>America/Argentina/Rio_Gallegos</option><option>America/Argentina/Salta</option>
<option>America/Argentina/San_Juan</option><option>America/Argentina/San_Luis</option>
<option>America/Argentina/Tucuman</option><option>America/Argentina/Ushuaia</option>
<option>America/Aruba</option><option>America/Asuncion</option><option>America/Atikokan</option>
<option>America/Bahia</option><option>America/Bahia_Banderas</option>
<option>America/Barbados</option><option>America/Belem</option><option>America/Belize</option>
<option>America/Blanc-Sablon</option><option>America/Boa_Vista</option>
<option>America/Bogota</option><option>America/Boise</option>
<option>America/Cambridge_Bay</option><option>America/Campo_Grande</option>
<option>America/Cancun</option><option>America/Caracas</option><option>America/Cayenne</option>
<option>America/Cayman</option><option>America/Chicago</option><option>America/Chihuahua</option>
<option>America/Costa_Rica</option><option>America/Creston</option><option>America/Cuiaba</option>
<option>America/Curacao</option><option>America/Danmarkshavn</option><option>America/Dawson</option>
<option>America/Dawson_Creek</option><option>America/Denver</option><option>America/Detroit</option>
<option>America/Dominica</option><option>America/Edmonton</option><option>America/Eirunepe</option>
<option>America/El_Salvador</option><option>America/Fortaleza</option>
<option>America/Glace_Bay</option><option>America/Godthab</option>
<option>America/Goose_Bay</option><option>America/Grand_Turk</option>
<option>America/Grenada</option><option>America/Guadeloupe</option>
<option>America/Guatemala</option><option>America/Guayaquil</option><option>America/Guyana</option>
<option>America/Halifax</option><option>America/Havana</option><option>America/Hermosillo</option>
<option>America/Indiana/Indianapolis</option><option>America/Indiana/Knox</option>
<option>America/Indiana/Marengo</option><option>America/Indiana/Petersburg</option>
<option>America/Indiana/Tell_City</option><option>America/Indiana/Vevay</option>
<option>America/Indiana/Vincennes</option><option>America/Indiana/Winamac</option>
<option>America/Inuvik</option><option>America/Iqaluit</option><option>America/Jamaica</option>
<option>America/Juneau</option><option>America/Kentucky/Louisville</option>
<option>America/Kentucky/Monticello</option><option>America/Kralendijk</option>
<option>America/La_Paz</option><option>America/Lima</option><option>America/Los_Angeles</option>
<option>America/Lower_Princes</option><option>America/Maceio</option>
<option>America/Managua</option><option>America/Manaus</option><option>America/Marigot</option>
<option>America/Martinique</option><option>America/Matamoros</option>
<option>America/Mazatlan</option><option>America/Menominee</option><option>America/Merida</option>
<option>America/Metlakatla</option><option>America/Mexico_City</option>
<option>America/Miquelon</option><option>America/Moncton</option>
<option>America/Monterrey</option><option>America/Montevideo</option>
<option>America/Montserrat</option><option>America/Nassau</option>
<option>America/New_York</option><option>America/Nipigon</option><option>America/Nome</option>
<option>America/Noronha</option><option>America/North_Dakota/Beulah</option>
<option>America/North_Dakota/Center</option><option>America/North_Dakota/New_Salem</option>
<option>America/Ojinaga</option><option>America/Panama</option>
<option>America/Pangnirtung</option><option>America/Paramaribo</option>
<option>America/Phoenix</option><option>America/Port-au-Prince</option>
<option>America/Port_of_Spain</option><option>America/Porto_Velho</option>
<option>America/Puerto_Rico</option><option>America/Rainy_River</option>
<option>America/Rankin_Inlet</option><option>America/Recife</option>
<option>America/Regina</option><option>America/Resolute</option>
<option>America/Rio_Branco</option><option>America/Santa_Isabel</option>
<option>America/Santarem</option><option>America/Santiago</option>
<option>America/Santo_Domingo</option><option>America/Sao_Paulo</option>
<option>America/Scoresbysund</option><option>America/Sitka</option>
<option>America/St_Barthelemy</option><option>America/St_Johns</option>
<option>America/St_Kitts</option><option>America/St_Lucia</option>
<option>America/St_Thomas</option><option>America/St_Vincent</option>
<option>America/Swift_Current</option><option>America/Tegucigalpa</option>
<option>America/Thule</option><option>America/Thunder_Bay</option>
<option>America/Tijuana</option><option>America/Toronto</option><option>America/Tortola</option>
<option>America/Vancouver</option><option>America/Whitehorse</option>
<option>America/Winnipeg</option><option>America/Yakutat</option>
<option>America/Yellowknife</option>
<option>Antarctica/Casey</option><option>Antarctica/Davis</option>
<option>Antarctica/DumontDUrville</option><option>Antarctica/Macquarie</option>
<option>Antarctica/Mawson</option><option>Antarctica/McMurdo</option>
<option>Antarctica/Palmer</option><option>Antarctica/Rothera</option>
<option>Antarctica/Syowa</option><option>Antarctica/Vostok</option>
<option>Arctic/Longyearbyen</option>
<option>Asia/Aden</option><option>Asia/Almaty</option><option>Asia/Amman</option>
<option>Asia/Anadyr</option><option>Asia/Aqtau</option><option>Asia/Aqtobe</option>
<option>Asia/Ashgabat</option><option>Asia/Baghdad</option><option>Asia/Bahrain</option>
<option>Asia/Baku</option><option>Asia/Bangkok</option><option>Asia/Beirut</option>
<option>Asia/Bishkek</option><option>Asia/Brunei</option><option>Asia/Choibalsan</option>
<option>Asia/Chongqing</option><option>Asia/Colombo</option><option>Asia/Damascus</option>
<option>Asia/Dhaka</option><option>Asia/Dili</option><option>Asia/Dubai</option>
<option>Asia/Dushanbe</option><option>Asia/Gaza</option><option>Asia/Harbin</option>
<option>Asia/Hebron</option><option>Asia/Ho_Chi_Minh</option><option>Asia/Hong_Kong</option>
<option>Asia/Hovd</option><option>Asia/Irkutsk</option><option>Asia/Jakarta</option>
<option>Asia/Jayapura</option><option>Asia/Jerusalem</option><option>Asia/Kabul</option>
<option>Asia/Kamchatka</option><option>Asia/Karachi</option><option>Asia/Kashgar</option>
<option>Asia/Kathmandu</option><option>Asia/Khandyga</option><option>Asia/Kolkata</option>
<option>Asia/Krasnoyarsk</option><option>Asia/Kuala_Lumpur</option><option>Asia/Kuching</option>
<option>Asia/Kuwait</option><option>Asia/Macau</option><option>Asia/Magadan</option>
<option>Asia/Makassar</option><option>Asia/Manila</option><option>Asia/Muscat</option>
<option>Asia/Nicosia</option><option>Asia/Novokuznetsk</option><option>Asia/Novosibirsk</option>
<option>Asia/Omsk</option><option>Asia/Oral</option><option>Asia/Phnom_Penh</option>
<option>Asia/Pontianak</option><option>Asia/Pyongyang</option><option>Asia/Qatar</option>
<option>Asia/Qyzylorda</option><option>Asia/Rangoon</option><option>Asia/Riyadh</option>
<option>Asia/Sakhalin</option><option>Asia/Samarkand</option><option>Asia/Seoul</option>
<option>Asia/Shanghai</option><option>Asia/Singapore</option><option>Asia/Taipei</option>
<option>Asia/Tashkent</option><option>Asia/Tbilisi</option><option>Asia/Tehran</option>
<option>Asia/Thimphu</option><option>Asia/Tokyo</option><option>Asia/Ulaanbaatar</option>
<option>Asia/Urumqi</option><option>Asia/Ust-Nera</option><option>Asia/Vientiane</option>
<option>Asia/Vladivostok</option><option>Asia/Yakutsk</option><option>Asia/Yekaterinburg</option>
<option>Asia/Yerevan</option>
<option>Atlantic/Azores</option><option>Atlantic/Bermuda</option><option>Atlantic/Canary</option>
<option>Atlantic/Cape_Verde</option><option>Atlantic/Faroe</option>
<option>Atlantic/Madeira</option><option>Atlantic/Reykjavik</option>
<option>Atlantic/South_Georgia</option><option>Atlantic/St_Helena</option>
<option>Atlantic/Stanley</option>
<option>Australia/Adelaide</option><option>Australia/Brisbane</option>
<option>Australia/Broken_Hill</option><option>Australia/Currie</option>
<option>Australia/Darwin</option><option>Australia/Eucla</option>
<option>Australia/Hobart</option><option>Australia/Lindeman</option>
<option>Australia/Lord_Howe</option><option>Australia/Melbourne</option>
<option>Australia/Perth</option><option>Australia/Sydney</option>
<option>Europe/Amsterdam</option><option>Europe/Andorra</option><option>Europe/Athens</option>
<option>Europe/Belgrade</option><option>Europe/Berlin</option><option>Europe/Bratislava</option>
<option>Europe/Brussels</option><option>Europe/Bucharest</option><option>Europe/Budapest</option>
<option>Europe/Busingen</option><option>Europe/Chisinau</option><option>Europe/Copenhagen</option>
<option>Europe/Dublin</option><option>Europe/Gibraltar</option><option>Europe/Guernsey</option>
<option>Europe/Helsinki</option><option>Europe/Isle_of_Man</option><option>Europe/Istanbul</option>
<option>Europe/Jersey</option><option>Europe/Kaliningrad</option><option>Europe/Kiev</option>
<option>Europe/Lisbon</option><option>Europe/Ljubljana</option><option>Europe/London</option>
<option>Europe/Luxembourg</option><option>Europe/Madrid</option><option>Europe/Malta</option>
<option>Europe/Mariehamn</option><option>Europe/Minsk</option><option>Europe/Monaco</option>
<option>Europe/Moscow</option><option>Europe/Nicosia</option><option>Europe/Oslo</option>
<option>Europe/Paris</option><option>Europe/Podgorica</option><option>Europe/Prague</option>
<option>Europe/Riga</option><option>Europe/Rome</option><option>Europe/Samara</option>
<option>Europe/San_Marino</option><option>Europe/Sarajevo</option>
<option>Europe/Simferopol</option><option>Europe/Skopje</option><option>Europe/Sofia</option>
<option>Europe/Stockholm</option><option>Europe/Tallinn</option><option>Europe/Tirane</option>
<option>Europe/Uzhgorod</option><option>Europe/Vaduz</option><option>Europe/Vatican</option>
<option>Europe/Vienna</option><option>Europe/Vilnius</option><option>Europe/Volgograd</option>
<option>Europe/Warsaw</option><option>Europe/Zagreb</option><option>Europe/Zaporozhye</option>
<option>Europe/Zurich</option>
<option>Indian/Antananarivo</option><option>Indian/Chagos</option>
<option>Indian/Christmas</option><option>Indian/Cocos</option><option>Indian/Comoro</option>
<option>Indian/Kerguelen</option><option>Indian/Mahe</option><option>Indian/Maldives</option>
<option>Indian/Mauritius</option><option>Indian/Mayotte</option><option>Indian/Reunion</option>
<option>Pacific/Apia</option><option>Pacific/Auckland</option><option>Pacific/Chatham</option>
<option>Pacific/Chuuk</option><option>Pacific/Easter</option><option>Pacific/Efate</option>
<option>Pacific/Enderbury</option><option>Pacific/Fakaofo</option><option>Pacific/Fiji</option>
<option>Pacific/Funafuti</option><option>Pacific/Galapagos</option>
<option>Pacific/Gambier</option><option>Pacific/Guadalcanal</option><option>Pacific/Guam</option>
<option>Pacific/Honolulu</option><option>Pacific/Johnston</option>
<option>Pacific/Kiritimati</option><option>Pacific/Kosrae</option>
<option>Pacific/Kwajalein</option><option>Pacific/Majuro</option>
<option>Pacific/Marquesas</option><option>Pacific/Midway</option><option>Pacific/Nauru</option>
<option>Pacific/Niue</option><option>Pacific/Norfolk</option><option>Pacific/Noumea</option>
<option>Pacific/Pago_Pago</option><option>Pacific/Palau</option><option>Pacific/Pitcairn</option>
<option>Pacific/Pohnpei</option><option>Pacific/Port_Moresby</option>
<option>Pacific/Rarotonga</option><option>Pacific/Saipan</option><option>Pacific/Tahiti</option>
<option>Pacific/Tarawa</option><option>Pacific/Tongatapu</option><option>Pacific/Wake</option>
<option>Pacific/Wallis</option>
</datalist>
{{template "configure-toolbar" .}}
</div>
{{end}}
@@ -0,0 +1,261 @@
{{define "configure-users.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
<div class="configure-page">
{{if .Error}}
<div class="alert alert-error">{{.Error}}</div>
{{end}}
<section class="info-grid">
{{/* ── Users ───────────────────────────────────────────────────────── */}}
<section class="info-card info-grid-span-2">
<div class="card-header">Users</div>
<div class="data-table-wrap">
<table class="data-table cfg-table">
<thead>
<tr>
<th>Username</th>
<th>Shell</th>
<th style="text-align:center">SSH Keys</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $i, $u := .Users}}
<tr>
<td>
<button class="key-row-toggle" type="button"
aria-expanded="false" data-target="user-{{$i}}">
<span class="key-row-arrow" aria-hidden="true"></span>{{.Name}}
</button>
</td>
<td>
<form hx-post="/configure/users/{{.Name}}/shell" hx-swap="none"
class="user-shell-form">
<select class="cfg-input" name="shell">
<option value="infix-system:clish" {{if eq .Shell "infix-system:clish"}}selected{{end}}>CLI Shell</option>
<option value="infix-system:bash" {{if eq .Shell "infix-system:bash" }}selected{{end}}>Bash</option>
<option value="infix-system:sh" {{if eq .Shell "infix-system:sh" }}selected{{end}}>Sh</option>
<option value="infix-system:false" {{if eq .Shell "infix-system:false"}}selected{{end}}>Disabled</option>
</select>
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
</form>
</td>
<td style="text-align:center">{{.KeyCount}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/users/{{.Name}}"
hx-confirm="Delete user {{.Name}}?"
hx-swap="none"
title="Delete user">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</td>
</tr>
<tr class="key-detail-row" id="key-detail-user-{{$i}}">
<td colspan="4" class="key-detail-cell">
<div class="key-detail-body">
{{/* Change password */}}
<form hx-post="/configure/users/{{.Name}}/password" hx-swap="none">
<div class="section-subtitle">Change Password</div>
<table class="info-table">
<tr>
<th>New password</th>
<td><input class="cfg-input" type="password" name="password"
autocomplete="new-password"
placeholder="Leave blank to keep current"></td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Change Password</button>
<span class="cfg-save-status"></span>
</div>
</form>
{{/* Authorized SSH keys */}}
<div>
<div class="section-subtitle">Authorized SSH Keys ({{.KeyCount}})</div>
{{if .AuthorizedKeys}}
<div class="data-table-wrap">
<table class="data-table cfg-table">
<thead>
<tr><th>Name</th><th>Algorithm</th><th></th></tr>
</thead>
<tbody>
{{range .AuthorizedKeys}}
<tr>
<td>{{.Name}}</td>
<td>{{.Algorithm}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/users/{{$u.Name}}/keys/{{.Name}}"
hx-confirm="Remove key {{.Name}}?"
hx-swap="none"
title="Remove key">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<form hx-post="/configure/users/{{.Name}}/keys" hx-swap="none"
style="margin-top:0.5rem">
<table class="info-table">
<tr>
<th>Key name</th>
<td><input class="cfg-input" type="text" name="key_name"
placeholder="e.g. laptop"></td>
</tr>
<tr>
<th>Public key</th>
<td><textarea class="cfg-input cfg-input-mono" name="key_data" rows="3"
placeholder="ssh-rsa AAAA… or ssh-ed25519 AAAA…"></textarea></td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Add Key</button>
<span class="cfg-save-status"></span>
</div>
</form>
</div>
</div>
</td>
</tr>
{{end}}
{{/* Hidden add-user row — revealed by the + Add user button */}}
<tr id="add-user-row" hidden>
<td colspan="4" class="key-detail-cell">
<form hx-post="/configure/users" hx-swap="none" class="user-add-inline">
<input class="cfg-input" type="text" name="username" required
pattern="[a-zA-Z0-9_][a-zA-Z0-9_.-]*" placeholder="Username">
<input class="cfg-input" type="password" name="password" required
autocomplete="new-password" placeholder="Password">
<select class="cfg-input" name="shell">
<option value="infix-system:clish">CLI Shell</option>
<option value="infix-system:bash">Bash</option>
<option value="infix-system:sh">Sh</option>
<option value="infix-system:false">Disabled</option>
</select>
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<button type="button" class="btn btn-sm" data-hide="add-user-row">Cancel</button>
<span class="cfg-save-status"></span>
</form>
</td>
</tr>
</tbody>
</table>
</div>
<div class="cfg-card-footer">
<button type="button" class="btn-add-row" data-show="add-user-row">+ Add user</button>
</div>
</section>
{{/* ── Groups ─────────────────────────────────────────────────────── */}}
<section class="info-card info-grid-span-2">
<div class="card-header">Groups</div>
<div class="data-table-wrap">
<table class="data-table cfg-table">
<thead>
<tr>
<th>Group</th>
<th>Members</th>
</tr>
</thead>
<tbody>
{{range $i, $g := .Groups}}
<tr>
<td>
<button class="key-row-toggle" type="button"
aria-expanded="false" data-target="group-{{$i}}">
<span class="key-row-arrow" aria-hidden="true"></span>{{.Name}}
</button>
</td>
<td>{{.MembersSummary}}</td>
</tr>
<tr class="key-detail-row" id="key-detail-group-{{$i}}">
<td colspan="2" class="key-detail-cell">
<div class="key-detail-body">
<div class="section-subtitle">Members</div>
{{if .UserNames}}
<div class="data-table-wrap">
<table class="data-table cfg-table">
<thead><tr><th>Username</th><th></th></tr></thead>
<tbody>
{{range .UserNames}}
<tr>
<td>{{.}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/users/groups/{{$g.Name}}/members/{{.}}"
hx-confirm="Remove {{.}} from group {{$g.Name}}?"
hx-target="closest tr"
hx-swap="delete"
title="Remove from group">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p style="color:var(--text-muted);font-size:0.85rem">No members.</p>
{{end}}
{{if .Available}}
<form hx-post="/configure/users/groups/{{.Name}}/members" hx-swap="none"
style="margin-top:0.75rem">
<div class="section-subtitle">Add Members</div>
<select class="cfg-input" name="members" multiple size="4">
{{range .Available}}<option value="{{.}}">{{.}}</option>{{end}}
</select>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Add Selected</button>
<span class="cfg-save-status"></span>
</div>
</form>
{{end}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</section>
</section>
{{template "configure-toolbar" .}}
</div>
{{end}}
+117
View File
@@ -0,0 +1,117 @@
{{define "nacm.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
{{if .Error}}
<div class="alert alert-error">{{.Error}}</div>
{{end}}
<section class="info-grid">
{{if not .Error}}
<div class="info-card">
<div class="card-header">NACM Settings</div>
<table class="info-table">
<tr><th>Enabled</th><td>{{.Enabled}}</td></tr>
<tr><th>Default read</th><td>{{.ReadDefault}}</td></tr>
<tr><th>Default write</th><td>{{.WriteDefault}}</td></tr>
<tr><th>Default exec</th><td>{{.ExecDefault}}</td></tr>
<tr><th>Denied operations</th><td>{{.DeniedOperations}}</td></tr>
<tr><th>Denied writes</th><td>{{.DeniedDataWrites}}</td></tr>
<tr><th>Denied notifications</th><td>{{.DeniedNotifications}}</td></tr>
</table>
</div>
{{end}}
{{if .Matrix}}
<div class="info-card">
<div class="card-header">Group Permissions</div>
<div class="data-table-wrap">
<table class="data-table nacm-matrix">
<thead>
<tr>
<th>Group</th>
<th class="nacm-col">Read</th>
<th class="nacm-col">Write</th>
<th class="nacm-col">Exec</th>
</tr>
</thead>
<tbody>
{{range .Matrix}}
<tr>
<td class="nacm-group">{{.Name}}</td>
<td class="nacm-col"><span class="nacm-cell {{.Read.Class}}">{{.Read.Symbol}}</span></td>
<td class="nacm-col"><span class="nacm-cell {{.Write.Class}}">{{.Write.Symbol}}</span></td>
<td class="nacm-col"><span class="nacm-cell {{.Exec.Class}}">{{.Exec.Symbol}}</span></td>
</tr>
{{if .Restrictions}}
<tr class="nacm-restrictions-row">
<td></td>
<td colspan="3" class="nacm-restrictions">Restricted: {{range $i, $r := .Restrictions}}{{if $i}}, {{end}}{{$r}}{{end}}</td>
</tr>
{{end}}
{{end}}
</tbody>
</table>
</div>
<div class="nacm-legend">
<span class="nacm-cell nacm-full"></span> Full &nbsp;
<span class="nacm-cell nacm-restricted"></span> Restricted &nbsp;
<span class="nacm-cell nacm-denied"></span> Denied
</div>
</div>
{{end}}
{{if .Users}}
<div class="info-card">
<div class="card-header">Users</div>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>User</th>
<th>Shell</th>
<th>Login</th>
</tr>
</thead>
<tbody>
{{range .Users}}
<tr>
<td>{{.Name}}</td>
<td>{{.Shell}}</td>
<td>{{.Login}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{if .Groups}}
<div class="info-card">
<div class="card-header">Groups</div>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Group</th>
<th>Members</th>
</tr>
</thead>
<tbody>
{{range .Groups}}
<tr>
<td>{{.Name}}</td>
<td>{{.Members}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
</section>
{{end}}