mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
MODULES=(
|
||||
"infix-services -e web-ui"
|
||||
)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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=...
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
@@ -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 → running</span></h3>
|
||||
<button type="button" class="btn-close" aria-label="Close"
|
||||
data-close-modal="config-diff-dialog">×</button>
|
||||
</header>
|
||||
<div class="iface-modal-body">
|
||||
<div id="config-diff-body" class="config-diff-body">
|
||||
<div class="diff-status">Loading…</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}}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user