Merge pull request #1539 from kernelkit/web-test-check

Web test check
This commit is contained in:
Joachim Wiberg
2026-06-17 15:33:27 +02:00
committed by GitHub
20 changed files with 448 additions and 140 deletions
+7
View File
@@ -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
+1 -1
View File
@@ -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"
+12
View File
@@ -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
*/
+3
View File
@@ -0,0 +1,3 @@
MODULES=(
"infix-services -e web-ui"
)
+1 -3
View File
@@ -79,9 +79,7 @@ func (h *LoginHandler) DoLogin(w http.ResponseWriter, r *http.Request) {
// The external web-app shortcuts (console/ttyd, netbrowse) are
// config-gated; fold them into the same feature map so templates gate
// on .Capabilities.Has "console" / "netbrowse".
console, netbrowse := handlers.DetectWebShortcuts(ctx, h.RC)
caps.Features()["console"] = console
caps.Features()["netbrowse"] = netbrowse
handlers.ApplyWebShortcuts(ctx, h.RC, caps.Features())
// The User's Guide is bundled at build time (a filesystem check, not
// config); gate the Help entry on its presence.
caps.Features()["docs"] = handlers.DetectDocs()
@@ -109,6 +109,14 @@ func DetectWebShortcuts(ctx context.Context, rc restconf.Fetcher) (console, netb
return cfg.Web.Console.Enabled, cfg.Web.Netbrowse.Enabled
}
// ApplyWebShortcuts writes the console/netbrowse capability flags into features
// from running-config. These two are the only config-toggleable shortcuts, so
// keeping the keys in one place lets both login (baking into the session) and
// the per-page-load refresh share them.
func ApplyWebShortcuts(ctx context.Context, rc restconf.Fetcher, features map[string]bool) {
features["console"], features["netbrowse"] = DetectWebShortcuts(ctx, rc)
}
func DetectCapabilities(ctx context.Context, rc restconf.Fetcher) *Capabilities {
var lib yangLibrary
if err := rc.Get(ctx, "/data/ietf-yang-library:yang-library", &lib); err != nil {
+154 -4
View File
@@ -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) {
@@ -101,19 +109,49 @@ func applyError(w http.ResponseWriter, op string, err error) {
http.Error(w, "Could not reach the device: "+err.Error(), http.StatusBadGateway)
}
// Apply copies candidate → running, activating all staged changes atomically.
// Sets the cfg-unsaved cookie so the persistent banner appears until startup is saved.
// Apply copies candidate → running, activating all staged changes atomically,
// then updates the unsaved-changes banner to match the running-vs-startup state.
// POST /configure/apply
func (h *ConfigureHandler) Apply(w http.ResponseWriter, r *http.Request) {
if err := h.RC.CopyDatastore(r.Context(), "candidate", "running"); err != nil {
applyError(w, "apply", err)
return
}
setCfgUnsaved(w)
updateCfgUnsaved(r.Context(), h.RC, w)
w.Header().Set("HX-Refresh", "true")
w.WriteHeader(http.StatusNoContent)
}
// configPair fetches the startup and running datastores — the shared basis for
// the unsaved-state check (updateCfgUnsaved) and the config diff (configDiff).
func configPair(ctx context.Context, rc restconf.Fetcher) (startup, running json.RawMessage, err error) {
if startup, err = rc.GetDatastore(ctx, "startup"); err != nil {
return nil, nil, fmt.Errorf("read startup-config: %w", err)
}
if running, err = rc.GetDatastore(ctx, "running"); err != nil {
return nil, nil, fmt.Errorf("read running-config: %w", err)
}
return startup, running, nil
}
// updateCfgUnsaved sets or clears the cfg-unsaved banner cookie to match the
// actual state: set when running-config differs from startup, cleared when they
// are byte-identical (an Apply or restore can revert an out-of-band change so
// nothing is unsaved). Byte comparison is reliable given the shared serializer
// (the basis the config diff uses); a read error fails open and shows the
// banner. Call after any operation that writes running-config.
func updateCfgUnsaved(ctx context.Context, rc restconf.Fetcher, w http.ResponseWriter) {
startup, running, err := configPair(ctx, rc)
if err != nil {
log.Printf("configure: unsaved-state check: %v", err)
}
if err == nil && bytes.Equal(running, startup) {
clearCfgUnsaved(w)
} else {
setCfgUnsaved(w)
}
}
// Abort copies running → candidate, discarding all staged changes.
// POST /configure/abort
func (h *ConfigureHandler) Abort(w http.ResponseWriter, r *http.Request) {
@@ -142,6 +180,118 @@ 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, running, err := configPair(ctx, h.RC)
if err != nil {
return "", 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=...
+32 -4
View File
@@ -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,16 +103,42 @@ 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
if mgr != nil {
data.ShellOptions = schema.OptionsFor(mgr, shellPath)
for _, o := range data.ShellOptions {
if o.IsDefault {
defaultShell = o.Label
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.
// 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),
})
}
@@ -171,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)
@@ -245,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)
+42 -5
View File
@@ -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 {
+3 -1
View File
@@ -395,7 +395,9 @@ func (h *SystemHandler) RestoreConfig(w http.ResponseWriter, r *http.Request) {
}
if target == "running" {
setCfgUnsaved(w)
// Restoring the same config that's already in startup leaves nothing
// unsaved, so reflect the actual running-vs-startup state.
updateCfgUnsaved(r.Context(), h.RC, w)
w.Header().Set("HX-Refresh", "true")
w.WriteHeader(http.StatusNoContent)
return
+3 -1
View File
@@ -1728,7 +1728,9 @@ func (h *TreeHandler) TogglePresence(w http.ResponseWriter, r *http.Request) {
exists = true
}
setCfgUnsaved(w)
// No setCfgUnsaved here — this only modifies candidate. The cfg-unsaved
// banner is specifically about "running differs from startup," set after
// Apply / RestoreConfig-to-running (matching the other tree-edit handlers).
// Re-render the right-pane leaf group with the updated presence state.
gd := h.buildLeafGroup(r, mgr, path, node.Name, "container")
+18 -2
View File
@@ -3,6 +3,7 @@
package server
import (
"maps"
"net/http"
"net/url"
"strings"
@@ -19,7 +20,7 @@ const cookieName = "session"
// the token up in the in-memory store, and attaches the session's
// credentials to the context. Unauthenticated requests are
// redirected to /login (or get a 401 if the request comes from HTMX).
func authMiddleware(store *auth.SessionStore, next http.Handler) http.Handler {
func authMiddleware(store *auth.SessionStore, rc restconf.Fetcher, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isPublicPath(r.URL.Path) {
next.ServeHTTP(w, r)
@@ -38,11 +39,13 @@ func authMiddleware(store *auth.SessionStore, next http.Handler) http.Handler {
return
}
polling := isPollingPath(r.URL.Path)
// Sliding window: extend the session on user-driven requests.
// Background polling endpoints are excluded so an idle user
// (just leaving the dashboard open in a tab) still hits the
// timeout.
if !isPollingPath(r.URL.Path) {
if !polling {
store.Refresh(cookie.Value)
}
@@ -51,6 +54,19 @@ func authMiddleware(store *auth.SessionStore, next http.Handler) http.Handler {
Password: password,
})
ctx = security.WithToken(ctx, csrf)
// The console / netbrowse shortcuts are toggleable in running-config at
// runtime, unlike the other (per-boot) capabilities baked into the
// session at login. Re-read them on full page loads — the only time
// the topbar/user-menu that host them re-render — so toggling takes
// effect on reload without re-login. htmx fragment swaps and pollers
// never re-draw them, so they're skipped (no read per request).
fullPageLoad := !polling && r.Header.Get("HX-Request") != "true"
if rc != nil && fullPageLoad {
features = maps.Clone(features) // don't mutate the shared session map
handlers.ApplyWebShortcuts(ctx, rc, features)
}
ctx = handlers.ContextWithCapabilities(ctx, handlers.NewCapabilities(features))
next.ServeHTTP(w, r.WithContext(ctx))
})
+7 -2
View File
@@ -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)
@@ -475,7 +480,7 @@ func New(
mux.HandleFunc("GET /status/tree/children", statusTreeH.TreeChildren)
mux.HandleFunc("GET /status/tree/node", statusTreeH.TreeNode)
handler := authMiddleware(store, mux)
handler := authMiddleware(store, rc, mux)
handler = csrfMiddleware(handler)
handler = securityHeadersMiddleware(handler)
return handler, nil
+27 -2
View File
@@ -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; }
@@ -2691,13 +2702,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 */
@@ -2756,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;
@@ -0,0 +1,9 @@
{{define "config-diff.html"}}
{{- if .Err}}
<div class="diff-status diff-status-error">Could not produce diff: {{.Err}}</div>
{{- else if .Lines}}
<div class="config-diff">{{range .Lines}}<span class="diff-line {{.Class}}">{{.Text}}</span>{{end}}</div>
{{- else}}
<div class="diff-status">Running and startup configuration are identical.</div>
{{- end}}
{{end}}
+30
View File
@@ -144,6 +144,14 @@
hx-confirm="Save running configuration to startup?">
Save to startup
</button>
<button type="button" class="btn btn-sm cfg-unsaved-diff-btn"
title="Show what differs between running and startup configuration."
hx-get="/configure/diff"
hx-target="#config-diff-body"
hx-swap="innerHTML"
data-show-modal="config-diff-dialog">
Show diff
</button>
</div>
{{end}}
<main id="content">
@@ -158,6 +166,28 @@
<button class="btn btn-outline" id="dialog-cancel">Cancel</button>
</div>
</dialog>
<dialog id="config-diff-dialog" class="iface-modal diff-modal" aria-modal="true">
<header class="iface-modal-header">
<h3>Configuration diff <span class="diff-subtitle">startup &rarr; running</span></h3>
<button type="button" class="btn-close" aria-label="Close"
data-close-modal="config-diff-dialog">&times;</button>
</header>
<div class="iface-modal-body">
<div id="config-diff-body" class="config-diff-body">
<div class="diff-status">Loading&hellip;</div>
</div>
</div>
<footer class="iface-modal-footer">
<button type="button" class="btn btn-sm btn-primary"
title="Copy running config to startup config so changes survive a reboot."
hx-post="/configure/save"
hx-swap="none">
Save to startup
</button>
<button type="button" class="btn btn-sm"
data-close-modal="config-diff-dialog">Close</button>
</footer>
</dialog>
</body>
</html>
{{end}}
+85 -29
View File
@@ -43,17 +43,7 @@
<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">
{{$shell := .Shell}}
{{range $.ShellOptions}}<option value="{{.Value}}" {{if or (eq .Value $shell) (and (eq $shell "") .IsDefault)}}selected{{end}}>{{.Label}}{{if .IsDefault}} (default){{end}}</option>{{end}}
</select>
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
</form>
</td>
<td>{{.ShellLabel}}</td>
<td style="text-align:center">{{.KeyCount}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
@@ -69,15 +59,47 @@
<td colspan="4" class="key-detail-cell">
<div class="key-detail-body">
{{/* Change password */}}
<form hx-post="/configure/users/{{.Name}}/password" hx-swap="none">
{{/* Login shell — match the option's bare .Label against the
user's effective ShellLabel; .Value carries a module prefix
the stored shell does not. */}}
<form hx-post="/configure/users/{{.Name}}/shell" hx-swap="none">
<div class="section-subtitle">Login Shell</div>
<select class="cfg-input" name="shell" style="max-width:16rem">
{{$shell := .ShellLabel}}
{{range $.ShellOptions}}<option value="{{.Value}}" {{if eq .Label $shell}}selected{{end}}>{{.Label}}{{if .IsDefault}} (default){{end}}</option>{{end}}
</select>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
{{/* Change password. type=text + CSS mask (cfg-input-mask)
dodges the browser password-manager save prompt, same as
the keystore secret fields. */}}
<form hx-post="/configure/users/{{.Name}}/password" hx-swap="none" autocomplete="off">
<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>
<th>New password{{template "field-info" (index $.Desc "password")}}</th>
<td>
<span class="cfg-secret-wrap">
<input class="cfg-input cfg-input-mask" type="text" name="password"
id="chpw-{{.Name}}" placeholder="Leave blank to keep current"
autocomplete="off" spellcheck="false"
data-1p-ignore="true" data-lpignore="true"
data-bwignore="true" data-form-type="other">
<button type="button" class="btn-icon cfg-secret-toggle"
data-toggle-mask="chpw-{{.Name}}"
aria-label="Show / hide password">👁</button>
</span>
</td>
</tr>
<tr>
<th>Hash{{template "field-info" (index $.Desc "crypt")}}</th>
<td><select class="cfg-input" name="crypt">
{{range $.CryptMethods}}<option value="{{.Value}}" {{if .Default}}selected{{end}}>{{.Label}}</option>{{end}}
</select></td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
@@ -142,21 +164,55 @@
</tr>
{{end}}
{{/* Hidden add-user row — revealed by the + Add user button */}}
{{/* Hidden add-user row — revealed by the + Add user button. The
password is a CSS-masked type=text (cfg-input-mask) to dodge the
browser password-manager save prompt, same as keystore secrets. */}}
<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">
{{range .ShellOptions}}<option value="{{.Value}}" {{if .IsDefault}}selected{{end}}>{{.Label}}{{if .IsDefault}} (default){{end}}</option>{{end}}
</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>
<div class="key-detail-body">
<form hx-post="/configure/users" hx-swap="none" autocomplete="off">
<div class="section-subtitle">Add User</div>
<table class="info-table">
<tr>
<th>Username{{template "field-info" (index $.Desc "username")}}</th>
<td><input class="cfg-input" type="text" name="username" required autocomplete="off"
pattern="[a-zA-Z0-9_][a-zA-Z0-9_.-]*" placeholder="Username"></td>
</tr>
<tr>
<th>Password{{template "field-info" (index $.Desc "password")}}</th>
<td>
<span class="cfg-secret-wrap">
<input class="cfg-input cfg-input-mask" type="text" name="password"
id="add-user-password" required placeholder="Password"
autocomplete="off" spellcheck="false"
data-1p-ignore="true" data-lpignore="true"
data-bwignore="true" data-form-type="other">
<button type="button" class="btn-icon cfg-secret-toggle"
data-toggle-mask="add-user-password"
aria-label="Show / hide password">👁</button>
</span>
</td>
</tr>
<tr>
<th>Hash{{template "field-info" (index $.Desc "crypt")}}</th>
<td><select class="cfg-input" name="crypt">
{{range $.CryptMethods}}<option value="{{.Value}}" {{if .Default}}selected{{end}}>{{.Label}}</option>{{end}}
</select></td>
</tr>
<tr>
<th>Shell{{template "field-info" (index $.Desc "shell")}}</th>
<td><select class="cfg-input" name="shell">
{{range $.ShellOptions}}<option value="{{.Value}}" {{if .IsDefault}}selected{{end}}>{{.Label}}{{if .IsDefault}} (default){{end}}</option>{{end}}
</select></td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<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>
</div>
</form>
</div>
</td>
</tr>
@@ -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:
-86
View File
@@ -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()