From 4d398bbf577b6758378a6955b85507639bf66dd1 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 16 Jun 2026 16:20:28 +0200 Subject: [PATCH 1/6] test: skip webui test when not available in build Add conditional YANG feature 'web-ui' to infix-services.yang, set when the webui is built. This feature is what the test now looks for to determine if it should be skipped or not. Also, clean up remnant of old test, which was entirely replaced by Mattias' login/csrf test. Signed-off-by: Joachim Wiberg --- package/confd/confd.mk | 7 ++ src/confd/yang/confd.inc | 2 +- src/confd/yang/confd/infix-services.yang | 12 +++ ...20.yang => infix-services@2026-06-17.yang} | 0 src/confd/yang/web.inc | 3 + test/case/services/webui/login_csrf/test.py | 6 ++ test/case/webui/basic/test.py | 86 ------------------- 7 files changed, 29 insertions(+), 87 deletions(-) rename src/confd/yang/confd/{infix-services@2026-03-20.yang => infix-services@2026-06-17.yang} (100%) create mode 100644 src/confd/yang/web.inc delete mode 100644 test/case/webui/basic/test.py diff --git a/package/confd/confd.mk b/package/confd/confd.mk index ccb50d2b..428e85b9 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -91,6 +91,12 @@ define CONFD_INSTALL_YANG_MODULES_GPS $(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/gps.inc endef endif +ifeq ($(BR2_PACKAGE_WEBUI),y) +define CONFD_INSTALL_YANG_MODULES_WEBUI + $(COMMON_SYSREPO_ENV) \ + $(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/web.inc +endef +endif # PER_PACKAGE_DIR # Since the last package in the dependency chain that runs sysrepoctl is confd, we need to @@ -121,6 +127,7 @@ CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_CONTAINERS CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_WIFI CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_GPS +CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_WEBUI CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_IN_ROMFS CONFD_TARGET_FINALIZE_HOOKS += CONFD_CLEANUP diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index c4b23343..44c29d4f 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -43,7 +43,7 @@ MODULES=( "infix-firewall-icmp-types@2025-04-26.yang" "infix-meta@2025-12-10.yang" "infix-system@2026-03-09.yang" - "infix-services@2026-03-20.yang" + "infix-services@2026-06-17.yang" "ieee802-ethernet-interface@2025-09-10.yang" "ieee802-ethernet-phy-type@2025-09-10.yang" "infix-ethernet-interface@2026-05-21.yang" diff --git a/src/confd/yang/confd/infix-services.yang b/src/confd/yang/confd/infix-services.yang index 7d64f848..c23651ec 100644 --- a/src/confd/yang/confd/infix-services.yang +++ b/src/confd/yang/confd/infix-services.yang @@ -31,6 +31,11 @@ module infix-services { contact "kernelkit@googlegroups.com"; description "Infix services, generic."; + revision 2026-06-17 { + description "Add web-ui feature, advertised when the web management + interface (webui) is built into the image."; + reference "internal"; + } revision 2026-03-20 { description "Add hostname leaf to mdns container for avahi host-name override. Add neighbors container to mdns for mDNS-SD neighbor table. @@ -71,6 +76,13 @@ module infix-services { reference "internal"; } + feature web-ui { + description "The web management interface (webui) is an optional build-time + feature in Infix. Advertised when it is built; it gates no + data nodes — the /web service tree (including restconf) is + present regardless, so the feature is a pure capability flag."; + } + /* * Data nodes */ diff --git a/src/confd/yang/confd/infix-services@2026-03-20.yang b/src/confd/yang/confd/infix-services@2026-06-17.yang similarity index 100% rename from src/confd/yang/confd/infix-services@2026-03-20.yang rename to src/confd/yang/confd/infix-services@2026-06-17.yang diff --git a/src/confd/yang/web.inc b/src/confd/yang/web.inc new file mode 100644 index 00000000..f9eb6130 --- /dev/null +++ b/src/confd/yang/web.inc @@ -0,0 +1,3 @@ +MODULES=( + "infix-services -e web-ui" +) diff --git a/test/case/services/webui/login_csrf/test.py b/test/case/services/webui/login_csrf/test.py index 27c843b3..910e6749 100755 --- a/test/case/services/webui/login_csrf/test.py +++ b/test/case/services/webui/login_csrf/test.py @@ -102,6 +102,12 @@ with infamy.Test() as test: host = target.location.host password = target.location.password + # The web UI is an optional build-time feature (BR2_PACKAGE_WEBUI); + # minimal builds omit it. infix-services advertises the "web-ui" + # feature only when it's built — skip rather than fail otherwise. + if not target.has_feature("infix-services", "web-ui"): + test.skip() + with test.step("Wait for the web UI to come up"): def webui_ready(): try: diff --git a/test/case/webui/basic/test.py b/test/case/webui/basic/test.py deleted file mode 100644 index c8f02687..00000000 --- a/test/case/webui/basic/test.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -""" -WebUI login and logout - -Smoke test that the WebUI is reachable, accepts admin credentials, -serves the dashboard while authenticated, and clears the session on -logout. Pure HTTP — no browser framework — so we only catch backend -and plumbing regressions (nginx down, Go service crashed, TLS or -RESTCONF auth broken, CSRF or session cookie logic broken, wrong -defaults). JS, htmx, and visual issues are deliberately out of -scope. -""" -import re -import time -import infamy -import requests - - -with infamy.Test() as test: - with test.step("Set up topology and wait for WebUI to come up"): - env = infamy.Env() - target = env.attach("target", "mgmt") - password = env.get_password("target") - - # Bracket the host so IPv6 link-local mgmt addresses - # (e.g. fe80::...%eth0) form a valid URL. - base = f"https://[{target.location.host}]" - - sess = requests.Session() - sess.verify = False # device ships a self-signed cert - - # env.attach() returns once NETCONF/RESTCONF is reachable, but - # nginx + the Go webui process may still be coming up. Poll - # /login briefly to ride out the early-boot window where - # default.conf's error_page would otherwise hand us /50x.html. - for _ in range(15): - try: - r = sess.get(f"{base}/login", timeout=10) - if r.status_code == 200 and 'name="csrf"' in r.text: - break - except requests.RequestException: - pass - time.sleep(1) - else: - test.fail("WebUI did not come up within 15s") - m = re.search(r'name="csrf"\s+value="([^"]+)"', r.text) - if not m: - test.fail("CSRF token field not found on /login") - login_csrf = m.group(1) - - with test.step("Authenticate as admin and load the dashboard"): - r = sess.post( - f"{base}/login", - data={"username": "admin", "password": password, "csrf": login_csrf}, - allow_redirects=False, - timeout=10, - ) - if r.status_code != 303: - test.fail(f"POST /login: expected 303, got {r.status_code}") - if "session" not in sess.cookies: - test.fail("session cookie not set after successful login") - - r = sess.get(f"{base}/", timeout=10) - if r.status_code != 200: - test.fail(f"GET /: expected 200, got {r.status_code}") - # base.html ships an htmx-config meta tag on every authenticated page. - if 'name="htmx-config"' not in r.text: - test.fail("htmx-config meta tag missing from /") - m = re.search(r'name="csrf-token"\s+content="([^"]+)"', r.text) - if not m: - test.fail("csrf-token meta tag not found on dashboard") - meta_csrf = m.group(1) - - with test.step("Logout clears the session cookie"): - r = sess.post( - f"{base}/logout", - data={"csrf": meta_csrf}, - allow_redirects=False, - timeout=10, - ) - if r.status_code != 303: - test.fail(f"POST /logout: expected 303, got {r.status_code}") - if "session" in sess.cookies: - test.fail("session cookie still present after logout") - - test.succeed() From 584d6c57df1e0621c3581678a4022e2010f26568 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 16 Jun 2026 17:01:17 +0200 Subject: [PATCH 2/6] webui: make Users summary static, edit shell on expand Move the per-user shell editor out of the always-visible row into the expanded detail; the summary now reports the shell as plain text. This also fixes the editor showing "bash" for non-bash users: the dropdown matched the module-prefixed option value against the bare stored shell, so it never matched and fell back to the first option. Signed-off-by: Joachim Wiberg --- .../internal/handlers/configure_users.go | 16 +++++++++- src/webui/static/css/style.css | 3 +- .../templates/pages/configure-users.html | 31 ++++++++++++------- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/webui/internal/handlers/configure_users.go b/src/webui/internal/handlers/configure_users.go index 4d674576..24975d84 100644 --- a/src/webui/internal/handlers/configure_users.go +++ b/src/webui/internal/handlers/configure_users.go @@ -104,13 +104,27 @@ func (h *ConfigureUsersHandler) Overview(w http.ResponseWriter, r *http.Request) const shellPath = "/ietf-system:system/authentication/user/infix-system:shell" mgr := h.Schema.Manager() data.Loading = mgr == nil + var defaultShell string if mgr != nil { data.ShellOptions = schema.OptionsFor(mgr, shellPath) + for _, o := range data.ShellOptions { + if o.IsDefault { + defaultShell = o.Label + break + } + } } for _, u := range raw.System.Auth.Users { + // Effective shell: the user's explicit shell, else the YANG default. + // Kept bare (no module prefix) so the static cell and the editor's + // selected-option test compare directly against the option .Label. + shell := schema.StripModulePrefix(u.Shell) + if shell == "" { + shell = defaultShell + } data.Users = append(data.Users, cfgUserDisplay{ cfgUserJSON: u, - ShellLabel: schema.StripModulePrefix(u.Shell), + ShellLabel: shell, KeyCount: len(u.AuthorizedKeys), }) } diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index e63cb44e..1cc9924b 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -2691,13 +2691,12 @@ details.cfg-fold[open] > summary::before { transform: rotate(90deg); } .btn-add-row:hover { text-decoration: underline; } /* Inline forms inside users table */ -.user-shell-form, .user-add-inline { +.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; } /* Add Interface modal dialog */ diff --git a/src/webui/templates/pages/configure-users.html b/src/webui/templates/pages/configure-users.html index 447beddb..d0decf22 100644 --- a/src/webui/templates/pages/configure-users.html +++ b/src/webui/templates/pages/configure-users.html @@ -43,17 +43,7 @@ {{.Name}} - -
- - - -
- + {{.ShellLabel}} {{.KeyCount}} + + + + {{/* Change password */}}
Change Password
@@ -145,8 +150,10 @@ {{/* Hidden add-user row — revealed by the + Add user button */}} - + From bbc01a5e548ebed10c0a313fb52a4f026233b4d6 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 16 Jun 2026 18:30:20 +0200 Subject: [PATCH 3/6] webui: add "Show diff" to the unsaved-config banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a button next to "Save to startup" that opens a modal showing a unified diff between startup-config and running-config, so the pending changes can be reviewed before persisting them. The handler fetches both datastores over RESTCONF (same serializer → low-noise diff), writes them to temp files, and runs busybox `diff -u`. Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/configure.go | 126 +++++++++++++++++- src/webui/internal/server/server.go | 7 +- src/webui/static/css/style.css | 26 ++++ .../templates/fragments/config-diff.html | 9 ++ src/webui/templates/layouts/base.html | 30 +++++ 5 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 src/webui/templates/fragments/config-diff.html diff --git a/src/webui/internal/handlers/configure.go b/src/webui/internal/handlers/configure.go index 75f9d9cc..27eb1523 100644 --- a/src/webui/internal/handlers/configure.go +++ b/src/webui/internal/handlers/configure.go @@ -3,11 +3,18 @@ package handlers import ( + "bytes" + "context" "encoding/json" "errors" + "fmt" + "html/template" "log" "net/http" + "os" + "os/exec" "strconv" + "strings" "time" "infix/webui/internal/restconf" @@ -28,7 +35,8 @@ var webuiStartTime = time.Now() // ConfigureHandler manages the candidate datastore lifecycle. type ConfigureHandler struct { - RC restconf.Fetcher + RC restconf.Fetcher + Template *template.Template } func setCfgUnsaved(w http.ResponseWriter) { @@ -142,6 +150,122 @@ func (h *ConfigureHandler) ApplyAndSave(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusNoContent) } +// configDiffLine is one classified line of unified-diff output. +type configDiffLine struct { + Class string + Text string +} + +// configDiffData is the template payload for the diff modal body. Neither +// field set means the configs are identical (rendered as such by the template). +type configDiffData struct { + Lines []configDiffLine + Err string +} + +// ConfigDiff renders a unified diff between startup-config and running-config +// for the unsaved-changes modal. Both datastores are fetched over RESTCONF +// (same serializer → deterministic, low-noise diff), written to temp files, +// and compared with busybox `diff -u`. Always responds 200 with an HTML +// fragment — including on error — so the connection monitor never reads a 5xx +// as "device disconnected" (same reasoning as applyError). +// GET /configure/diff +func (h *ConfigureHandler) ConfigDiff(w http.ResponseWriter, r *http.Request) { + var data configDiffData + out, err := h.configDiff(r.Context()) + switch { + case err != nil: + log.Printf("configure diff: %v", err) + data.Err = err.Error() + case strings.TrimSpace(out) != "": + data.Lines = classifyDiff(out) + } + if err := h.Template.ExecuteTemplate(w, "config-diff.html", data); err != nil { + log.Printf("configure diff: render: %v", err) + } +} + +// configDiff fetches startup and running config, writes each to a temp file, +// and returns the `diff -u` output (empty when the two are identical). +func (h *ConfigureHandler) configDiff(ctx context.Context) (string, error) { + startup, err := h.RC.GetDatastore(ctx, "startup") + if err != nil { + return "", fmt.Errorf("read startup-config: %w", err) + } + running, err := h.RC.GetDatastore(ctx, "running") + if err != nil { + return "", fmt.Errorf("read running-config: %w", err) + } + + startupFile, err := writeTempConfig("cfgdiff-startup-*.json", startup) + if err != nil { + return "", err + } + defer os.Remove(startupFile) + runningFile, err := writeTempConfig("cfgdiff-running-*.json", running) + if err != nil { + return "", err + } + defer os.Remove(runningFile) + + var stdout, stderr bytes.Buffer + // busybox diff supports -L (not --label); repeat it for each file. + cmd := exec.CommandContext(ctx, "diff", "-u", + "-L", "startup-config", "-L", "running-config", startupFile, runningFile) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err = cmd.Run() + // diff exit codes: 0 = identical, 1 = differences (normal), >1 = error. + var exit *exec.ExitError + if err != nil && (!errors.As(err, &exit) || exit.ExitCode() > 1) { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("diff failed: %s", msg) + } + return stdout.String(), nil +} + +// writeTempConfig writes data to a fresh 0600 temp file and returns its path. +func writeTempConfig(pattern string, data []byte) (string, error) { + f, err := os.CreateTemp("", pattern) + if err != nil { + return "", err + } + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(f.Name()) + return "", err + } + if err := f.Close(); err != nil { + os.Remove(f.Name()) + return "", err + } + return f.Name(), nil +} + +// classifyDiff tags each unified-diff line with a CSS class for colorization. +func classifyDiff(out string) []configDiffLine { + raw := strings.Split(strings.TrimRight(out, "\n"), "\n") + lines := make([]configDiffLine, 0, len(raw)) + for _, ln := range raw { + class := "diff-ctx" + switch { + case strings.HasPrefix(ln, "+++"), strings.HasPrefix(ln, "---"): + class = "diff-meta" + case strings.HasPrefix(ln, "@@"): + class = "diff-hunk" + case strings.HasPrefix(ln, "+"): + class = "diff-add" + case strings.HasPrefix(ln, "-"): + class = "diff-del" + } + lines = append(lines, configDiffLine{Class: class, Text: ln}) + } + return lines +} + // DeleteLeaf removes a single leaf from the candidate datastore so the YANG // default takes effect. Used by curated-page reset buttons. // DELETE /configure/leaf?path=...&redirect=... diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 0db03db6..46ad20ee 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -149,6 +149,10 @@ func New( if err != nil { return nil, err } + cfgDiffTmpl, err := template.ParseFS(templateFS, "fragments/config-diff.html") + if err != nil { + return nil, err + } ifFuncs := template.FuncMap{ "shortPMD": handlers.ShortenPMD, "add": func(a, b int) int { return a + b }, @@ -262,7 +266,7 @@ func New( 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} + cfg := &handlers.ConfigureHandler{RC: rc, Template: cfgDiffTmpl} cfgSys := &handlers.ConfigureSystemHandler{ Template: cfgSysTmpl, NTPTemplate: cfgNTPTmpl, @@ -353,6 +357,7 @@ func New( mux.HandleFunc("POST /configure/apply-and-save", cfg.ApplyAndSave) mux.HandleFunc("POST /configure/abort", cfg.Abort) mux.HandleFunc("POST /configure/save", cfg.Save) + mux.HandleFunc("GET /configure/diff", cfg.ConfigDiff) mux.HandleFunc("DELETE /configure/leaf", cfg.DeleteLeaf) mux.HandleFunc("GET /configure/system", cfgSys.Overview) mux.HandleFunc("GET /configure/ntp", cfgSys.OverviewNTP) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 1cc9924b..3c060ea0 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -2555,6 +2555,17 @@ details[open] > .cfg-multi-summary { font-size: 0.875rem; flex-shrink: 0; } +/* "Show diff" pairs with the filled "Save to startup" — same primary hue, + outlined so the save action stays visually dominant; fills on hover. */ +.cfg-unsaved-diff-btn { + background: transparent; + color: var(--primary); + border-color: var(--primary); +} +.cfg-unsaved-diff-btn:hover { + background: var(--primary); + color: #fff; +} /* cfgError inline feedback */ .cfg-save-status.error { color: var(--danger); cursor: pointer; } @@ -2755,6 +2766,21 @@ details.cfg-fold[open] > summary::before { transform: rotate(90deg); } padding: 0.6rem 1rem; border-top: 1px solid var(--border); } + +/* Configuration diff modal (startup vs running) */ +.diff-modal { min-width: 52rem; } +.diff-subtitle { color: var(--fg-muted); font-weight: normal; font-size: 0.8em; } +.diff-modal .iface-modal-body { padding: 0; max-height: 70vh; overflow: auto; } +.config-diff-body { font-size: 0.8rem; } +.config-diff { font-family: var(--font-mono); } +.diff-line { display: block; white-space: pre; padding: 0 0.75rem; } +.diff-meta { color: var(--fg-muted); } +.diff-hunk { color: var(--fg-muted); background: var(--border-subtle); } +.diff-add { background: rgba(34, 197, 94, 0.14); color: var(--green-600); } +.diff-del { background: rgba(239, 68, 68, 0.14); color: var(--red-600); } +.diff-status { padding: 1rem; color: var(--fg-muted); } +.diff-status-error { color: var(--danger); } + .iface-fields { border: none; padding: 0; diff --git a/src/webui/templates/fragments/config-diff.html b/src/webui/templates/fragments/config-diff.html new file mode 100644 index 00000000..7b3635dc --- /dev/null +++ b/src/webui/templates/fragments/config-diff.html @@ -0,0 +1,9 @@ +{{define "config-diff.html"}} +{{- if .Err}} +
Could not produce diff: {{.Err}}
+{{- else if .Lines}} +
{{range .Lines}}{{.Text}}{{end}}
+{{- else}} +
Running and startup configuration are identical.
+{{- end}} +{{end}} diff --git a/src/webui/templates/layouts/base.html b/src/webui/templates/layouts/base.html index 31ca736f..6e263666 100644 --- a/src/webui/templates/layouts/base.html +++ b/src/webui/templates/layouts/base.html @@ -144,6 +144,14 @@ hx-confirm="Save running configuration to startup?"> Save to startup + {{end}}
@@ -158,6 +166,28 @@ + +
+

Configuration diff startup → running

+ +
+
+
+
Loading…
+
+
+
+ + +
+
{{end}} From 322811d7129d7b7636bbb501225c1986b74605d5 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 16 Jun 2026 21:15:37 +0200 Subject: [PATCH 4/6] webui: select password hash, mask inputs, hover help on user forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Hash dropdown to the add-user and change-password forms — yescrypt (default), SHA-512, SHA-256, MD5 — passed through to mkpasswd. These are exactly the four hashes the infix-system:crypt-hash YANG type accepts; unknown values fall back to the default. Password inputs switch to the CSS-masked type=text used by keystore secrets, which keeps the browser password manager from offering to save device credentials. Each add-user field gets a YANG description as a hover tooltip. Signed-off-by: Joachim Wiberg --- .../internal/handlers/configure_users.go | 20 ++++- src/webui/internal/handlers/crypt.go | 47 ++++++++-- .../templates/pages/configure-users.html | 89 ++++++++++++++----- 3 files changed, 128 insertions(+), 28 deletions(-) diff --git a/src/webui/internal/handlers/configure_users.go b/src/webui/internal/handlers/configure_users.go index 24975d84..03209a15 100644 --- a/src/webui/internal/handlers/configure_users.go +++ b/src/webui/internal/handlers/configure_users.go @@ -66,6 +66,8 @@ type cfgUsersPageData struct { Groups []cfgGroupDisplay Error string ShellOptions []schema.IdentityOption + CryptMethods []CryptMethod + Desc map[string]string // YANG field descriptions for hover tooltips } // ─── Handler ───────────────────────────────────────────────────────────────── @@ -101,7 +103,9 @@ func (h *ConfigureUsersHandler) Overview(w http.ResponseWriter, r *http.Request) data.Error = "Could not read user configuration" } } - const shellPath = "/ietf-system:system/authentication/user/infix-system:shell" + const userPath = "/ietf-system:system/authentication/user" + const shellPath = userPath + "/infix-system:shell" + data.CryptMethods = CryptMethods mgr := h.Schema.Manager() data.Loading = mgr == nil var defaultShell string @@ -113,6 +117,16 @@ func (h *ConfigureUsersHandler) Overview(w http.ResponseWriter, r *http.Request) break } } + data.Desc = map[string]string{ + "username": schema.DescriptionOf(mgr, userPath+"/name"), + "password": schema.DescriptionOf(mgr, userPath+"/password"), + "shell": schema.DescriptionOf(mgr, shellPath), + // No YANG leaf models the hash algorithm (it's a property of the + // stored crypt-hash); describe the offered set, verified against + // the infix-system:crypt-hash typedef. + "crypt": "Password hashing algorithm. yescrypt (recommended) is the strongest; " + + "SHA-512, SHA-256, and MD5 are offered for compatibility.", + } } for _, u := range raw.System.Auth.Users { // Effective shell: the user's explicit shell, else the YANG default. @@ -185,7 +199,7 @@ func (h *ConfigureUsersHandler) AddUser(w http.ResponseWriter, r *http.Request) return } - hash, err := HashPassword(password) + hash, err := HashPassword(password, r.FormValue("crypt")) if err != nil { log.Printf("configure users add: hash: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -259,7 +273,7 @@ func (h *ConfigureUsersHandler) ChangePassword(w http.ResponseWriter, r *http.Re return } - hash, err := HashPassword(password) + hash, err := HashPassword(password, r.FormValue("crypt")) if err != nil { log.Printf("configure users password %q: hash: %v", name, err) http.Error(w, "Internal server error", http.StatusInternalServerError) diff --git a/src/webui/internal/handlers/crypt.go b/src/webui/internal/handlers/crypt.go index 430ac96a..a9eef94d 100644 --- a/src/webui/internal/handlers/crypt.go +++ b/src/webui/internal/handlers/crypt.go @@ -8,15 +8,52 @@ import ( "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) { +// CryptMethod is a password-hashing algorithm offered in the UI. +type CryptMethod struct { + Value string // form value and mkpasswd --method argument + Label string + Default bool +} + +// CryptMethods is the single source of truth for the hashes the UI offers, +// ordered strongest first. Only the four the infix-system:crypt-hash YANG type +// accepts ($y$/$6$/$5$/$1$) are listed — mkpasswd supports more (bcrypt, +// scrypt, …), but the password leaf's pattern would reject those on save. +var CryptMethods = []CryptMethod{ + {"yescrypt", "yescrypt (recommended)", true}, + {"sha512crypt", "SHA-512", false}, + {"sha256crypt", "SHA-256", false}, + {"md5crypt", "MD5", false}, +} + +// normalizeCryptMethod returns method if it's one we offer, otherwise the +// default (the entry flagged Default). This guards an empty or tampered form +// value from reaching mkpasswd --method. +func normalizeCryptMethod(method string) string { + def := CryptMethods[0].Value + for _, m := range CryptMethods { + if m.Default { + def = m.Value + } + if m.Value == method { + return method + } + } + return def +} + +// HashPassword returns a crypt hash of password using mkpasswd(1) with the +// given method. mkpasswd is available on Infix target systems and uses the +// system's libcrypt. An empty or unrecognised method (e.g. one the YANG type +// would reject) falls back to the default, so callers can pass the raw form +// value safely. +func HashPassword(password, method string) (string, error) { + method = normalizeCryptMethod(method) 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 := exec.Command(path, "--method="+method, "--password-fd=0") cmd.Stdin = strings.NewReader(password) out, err := cmd.Output() if err != nil { diff --git a/src/webui/templates/pages/configure-users.html b/src/webui/templates/pages/configure-users.html index d0decf22..6dfccd4b 100644 --- a/src/webui/templates/pages/configure-users.html +++ b/src/webui/templates/pages/configure-users.html @@ -74,15 +74,32 @@ - {{/* Change password */}} -
+ {{/* Change password. type=text + CSS mask (cfg-input-mask) + dodges the browser password-manager save prompt, same as + the keystore secret fields. */}} +
Change Password
- - + + + + + +
New passwordNew password{{template "field-info" (index $.Desc "password")}} + + + + +
Hash{{template "field-info" (index $.Desc "crypt")}}