Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package/confd/confd.mk
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/confd/yang/confd.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions src/confd/yang/confd/infix-services.yang
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
*/
Expand Down
3 changes: 3 additions & 0 deletions src/confd/yang/web.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
MODULES=(
"infix-services -e web-ui"
)
4 changes: 1 addition & 3 deletions src/webui/internal/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 8 additions & 0 deletions src/webui/internal/handlers/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
158 changes: 154 additions & 4 deletions src/webui/internal/handlers/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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=...
Expand Down
36 changes: 32 additions & 4 deletions src/webui/internal/handlers/configure_users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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),
})
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading