From 9d5fd5db2fb163aed871424308b1bf46b9908ff4 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 14 Jun 2026 18:26:43 +0200 Subject: [PATCH 01/59] bin: fix file permission regression in /cfg/startup-config.cfg admin@infix-00-00-00:~$ less /cfg/startup-config.cfg cat: can't open '/cfg/startup-config.cfg': Permission denied /cfg/startup-config.cfg: Permission denied admin@infix-00-00-00:~$ ls -l /cfg/ total 16 -rw------- 1 root wheel 13655 Jun 14 12:59 startup-config.cfg Signed-off-by: Joachim Wiberg --- src/bin/copy.c | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/bin/copy.c b/src/bin/copy.c index bd6a5972..2fcc8584 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -138,9 +138,16 @@ static void set_owner(const char *fn, const char *user) if (chown(fn, -1, gid) && errno != EPERM) { const struct group *gr = getgrgid(gid); - warn("setting group owner %s (%d) on %s", + warn("failed setting group owner %s (%d) on %s", gr ? gr->gr_name : "", gid, fn); } + + /* Make sure the group we just set can actually read/write the file. + * umask alone can't: the datastore export goes through a 0600 mkstemp + * temp and cp(1) propagates that mode to the destination. + */ + if (chmod(fn, 0660) && errno != EPERM) + warn("failed setting mode 0660 on %s", fn); } static const char *infix_ds(const char *text, const struct infix_ds **ds) From ad28fd9db547dec79700b8f39e6a91a4be9621ba Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 29 Mar 2026 17:14:55 +0200 Subject: [PATCH 02/59] statd: add contact + location to ietf-system:system operational The contact and location leaves were absent from the operational datastore. Fetch each leaf from the running ds tool and inject into ietf-system:system. Signed-off-by: Joachim Wiberg --- src/statd/python/yanger/ietf_system.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/statd/python/yanger/ietf_system.py b/src/statd/python/yanger/ietf_system.py index 60c7c6c0..0ef7a7b7 100644 --- a/src/statd/python/yanger/ietf_system.py +++ b/src/statd/python/yanger/ietf_system.py @@ -257,9 +257,16 @@ def add_software(out): insert(out, "infix-system:software", software) def add_hostname(out): - hostname = HOST.run(tuple(["hostname"])) + hostname = HOST.run(tuple(["hostname"])) out["hostname"] = hostname.strip() +def add_contact_location(out): + for name in ("contact", "location"): + data = HOST.run_json(("copy", "running", "-x", f"/system/{name}"), {}) + val = data.get("ietf-system:system", {}).get(name) + if val: + out[name] = val + def add_timezone(out): path = HOST.run(tuple("realpath /etc/localtime".split()), "") timezone = None @@ -448,6 +455,7 @@ def operational(): out_state = out["ietf-system:system-state"] out_system = out["ietf-system:system"] add_hostname(out_system) + add_contact_location(out_system) add_users(out_system) add_timezone(out_system) add_software(out_state) From f8f73b89cb4432badd8d3cfc6f2e7147e9c69f41 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 12 Jun 2026 10:27:55 +0200 Subject: [PATCH 03/59] statd: extend mount-points + advertised pmds per interface - Include /run and /tmp in mount-points, likely more interesting than /. - Include advertised link modes in operational to compare with supported Signed-off-by: Joachim Wiberg --- src/statd/python/yanger/ietf_interfaces/ethernet.py | 7 ++++--- src/statd/python/yanger/ietf_system.py | 9 +++++++-- test/case/statd/system/ietf-system.json | 12 ++++++++++++ test/case/statd/system/operational.json | 12 ++++++++++++ test/case/statd/system/system/run/df_-k_+run | 2 ++ test/case/statd/system/system/run/df_-k_+tmp | 2 ++ 6 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 test/case/statd/system/system/run/df_-k_+run create mode 100644 test/case/statd/system/system/run/df_-k_+tmp diff --git a/src/statd/python/yanger/ietf_interfaces/ethernet.py b/src/statd/python/yanger/ietf_interfaces/ethernet.py index 2a814c95..55003713 100644 --- a/src/statd/python/yanger/ietf_interfaces/ethernet.py +++ b/src/statd/python/yanger/ietf_interfaces/ethernet.py @@ -203,10 +203,11 @@ def link(ifname): if supported: eth["infix-ethernet-interface:supported-pmd-types"] = supported - # Suppress when advertised == supported — that's the default - # "advertise everything" state with no diagnostic value. + # Always emit when the PHY is advertising something — even when it equals + # the full supported set, displaying the list alongside supported is + # useful for "show interfaces detail" and the WebUI ethernet card. advertised = _ethtool_modes_to_pmd_identities(data.get("advertised-link-modes")) - if advertised and set(advertised) != set(supported): + if advertised: eth["auto-negotiation"]["infix-ethernet-interface:advertised-pmd-types"] = advertised speed_bps = None diff --git a/src/statd/python/yanger/ietf_system.py b/src/statd/python/yanger/ietf_system.py index 0ef7a7b7..f82af822 100644 --- a/src/statd/python/yanger/ietf_system.py +++ b/src/statd/python/yanger/ietf_system.py @@ -422,9 +422,14 @@ def add_resource_usage(out): except (FileNotFoundError, ValueError): pass - # Filesystem usage + # Filesystem usage. /run and /tmp are tmpfs (RAM-backed) and easily the + # scarcest writable storage on small embedded boards — surface them so + # operators can tell when the box is about to start dropping logs or + # failing to spool config diffs. / is the read-only rootfs (squashfs); + # always 100 % used by design but kept in the list so a flag/limit + # consumer can recognise it. filesystems = [] - for mount in ["/", "/var", "/cfg"]: + for mount in ["/", "/var", "/cfg", "/run", "/tmp"]: try: result = HOST.run_multiline(["df", "-k", mount], []) if len(result) > 1: diff --git a/test/case/statd/system/ietf-system.json b/test/case/statd/system/ietf-system.json index a8dde70b..e8575f7f 100644 --- a/test/case/statd/system/ietf-system.json +++ b/test/case/statd/system/ietf-system.json @@ -416,6 +416,18 @@ "size": "14073", "used": "67", "available": "12860" + }, + { + "mount-point": "/run", + "size": "51200", + "used": "128", + "available": "51072" + }, + { + "mount-point": "/tmp", + "size": "102400", + "used": "240", + "available": "102160" } ] } diff --git a/test/case/statd/system/operational.json b/test/case/statd/system/operational.json index 42832152..8a436a9d 100644 --- a/test/case/statd/system/operational.json +++ b/test/case/statd/system/operational.json @@ -111,6 +111,18 @@ "mount-point": "/cfg", "size": "14073", "used": "67" + }, + { + "available": "51072", + "mount-point": "/run", + "size": "51200", + "used": "128" + }, + { + "available": "102160", + "mount-point": "/tmp", + "size": "102400", + "used": "240" } ], "load-average": { diff --git a/test/case/statd/system/system/run/df_-k_+run b/test/case/statd/system/system/run/df_-k_+run new file mode 100644 index 00000000..40e8f48b --- /dev/null +++ b/test/case/statd/system/system/run/df_-k_+run @@ -0,0 +1,2 @@ +Filesystem 1K-blocks Used Available Use% Mounted on +tmpfs 51200 128 51072 1% /run diff --git a/test/case/statd/system/system/run/df_-k_+tmp b/test/case/statd/system/system/run/df_-k_+tmp new file mode 100644 index 00000000..a628d3f5 --- /dev/null +++ b/test/case/statd/system/system/run/df_-k_+tmp @@ -0,0 +1,2 @@ +Filesystem 1K-blocks Used Available Use% Mounted on +tmpfs 102400 240 102160 1% /tmp From a9c722fcd58a41e444f4b4b5c283bf4a6ee62352 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 14 Jun 2026 17:44:24 +0200 Subject: [PATCH 04/59] netbrowse: link neighbor cards to the resolved host, not adminurl's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit avahi resolves mDNS hostname conflicts at runtime by appending -2, -3, … but the advertiser writes its adminurl TXT record with the configured hostname and never updates it. A device renamed infix-2.local thus keeps advertising adminurl=http://infix.local, so its card linked to the other device. Rebase the adminurl onto the actually-resolved host (keeping scheme, port, and path); a no-op in the common single-device case. Signed-off-by: Joachim Wiberg --- src/netbrowse/browse.go | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/netbrowse/browse.go b/src/netbrowse/browse.go index 2f32b041..d06cab3e 100644 --- a/src/netbrowse/browse.go +++ b/src/netbrowse/browse.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log" + "net/url" "os/exec" "sort" "strings" @@ -221,7 +222,7 @@ func scan() map[string]Host { var url string if meta.adminurl != "" { - url = meta.adminurl + url = rebaseURLHost(meta.adminurl, link) } else if info.urlTemplate != "" { url = strings.NewReplacer( "{address}", address, @@ -340,7 +341,7 @@ func parseOperational(root *opRoot) map[string]Host { var url string if meta.adminurl != "" { - url = meta.adminurl + url = rebaseURLHost(meta.adminurl, link) } else if info.urlTemplate != "" { url = strings.NewReplacer( "{address}", addr, @@ -398,6 +399,30 @@ func scanAuto() map[string]Host { return parseOperational(root) } +// rebaseURLHost rewrites the host of raw to host, keeping scheme, port, and +// path. It corrects stale adminurl TXT records: avahi resolves hostname +// conflicts at runtime by appending -2, -3, … but the advertiser writes the +// adminurl with its configured hostname and never updates it, so a device +// renamed infix-2.local still advertises adminurl=http://infix.local — which +// resolves to the *other* device. The mDNS-resolved host is authoritative. +// A no-op when raw is unparseable, hostless, or already points at host (the +// common single-device case). +func rebaseURLHost(raw, host string) string { + if host == "" { + return raw + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return raw + } + if port := u.Port(); port != "" { + u.Host = host + ":" + port + } else { + u.Host = host + } + return u.String() +} + // decode handles avahi's DNS-SD escape sequences in service names: // - \DDD decimal value 0-255, e.g. \058 → ':' // - \X literal character X, e.g. \. → '.' From bc535099ec8ad09308c432df08d9877a3b326648 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 28 Mar 2026 09:21:30 +0100 Subject: [PATCH 05/59] webui: initial import Initial import of https://github.com/kernelkit/webui2 @ e344d3f Signed-off-by: Joachim Wiberg --- board/common/rootfs/etc/default/webui | 2 + .../common/rootfs/etc/nginx/app/restconf.conf | 1 + .../etc/nginx/restconf-access-local.conf | 3 + .../rootfs/etc/nginx/restconf-access.conf | 1 + board/common/rootfs/etc/nginx/restconf.app | 1 + configs/aarch64_defconfig | 3 +- configs/aarch64_minimal_defconfig | 1 + configs/arm_defconfig | 3 +- configs/arm_minimal_defconfig | 1 + configs/riscv64_defconfig | 3 +- configs/x86_64_defconfig | 3 +- configs/x86_64_minimal_defconfig | 1 + package/Config.in | 1 + .../landing}/default.conf | 0 package/landing/landing.mk | 2 + package/webui/50x.html | 30 + package/webui/Config.in | 9 + package/webui/default.conf | 24 + package/webui/webui.conf | 9 + package/webui/webui.mk | 27 + package/webui/webui.svc | 3 + src/confd/src/services.c | 34 +- src/webui/.gitignore | 1 + src/webui/LICENSE | 19 + src/webui/Makefile | 15 + src/webui/README.md | 120 ++ src/webui/go.mod | 3 + src/webui/internal/auth/login.go | 131 ++ src/webui/internal/auth/session.go | 3 + src/webui/internal/auth/store.go | 156 +++ src/webui/internal/handlers/capabilities.go | 98 ++ .../internal/handlers/capabilities_test.go | 124 ++ src/webui/internal/handlers/common.go | 21 + src/webui/internal/handlers/containers.go | 186 +++ .../internal/handlers/containers_test.go | 69 ++ src/webui/internal/handlers/dashboard.go | 469 +++++++ src/webui/internal/handlers/dashboard_test.go | 84 ++ src/webui/internal/handlers/dhcp.go | 170 +++ src/webui/internal/handlers/firewall.go | 281 +++++ src/webui/internal/handlers/interfaces.go | 988 +++++++++++++++ src/webui/internal/handlers/keystore.go | 190 +++ src/webui/internal/handlers/lldp.go | 138 +++ src/webui/internal/handlers/ntp.go | 147 +++ src/webui/internal/handlers/routing.go | 301 +++++ src/webui/internal/handlers/routing_test.go | 69 ++ src/webui/internal/handlers/services_test.go | 110 ++ src/webui/internal/handlers/system.go | 213 ++++ src/webui/internal/handlers/vpn.go | 230 ++++ src/webui/internal/handlers/vpn_test.go | 69 ++ src/webui/internal/handlers/wifi.go | 425 +++++++ src/webui/internal/handlers/wifi_test.go | 69 ++ src/webui/internal/restconf/client.go | 223 ++++ src/webui/internal/restconf/errors.go | 63 + src/webui/internal/restconf/fetcher.go | 16 + src/webui/internal/security/csrf.go | 102 ++ src/webui/internal/server/middleware.go | 176 +++ src/webui/internal/server/server.go | 161 +++ src/webui/internal/testutil/helpers.go | 95 ++ src/webui/main.go | 85 ++ src/webui/static/css/style.css | 1100 +++++++++++++++++ src/webui/static/img/icons/containers.svg | 5 + src/webui/static/img/icons/dashboard.svg | 6 + src/webui/static/img/icons/dhcp.svg | 4 + src/webui/static/img/icons/firewall.svg | 3 + src/webui/static/img/icons/firmware.svg | 5 + src/webui/static/img/icons/iface-bridge.svg | 6 + .../static/img/icons/iface-container.svg | 5 + src/webui/static/img/icons/iface-ethernet.svg | 3 + src/webui/static/img/icons/iface-gre.svg | 5 + src/webui/static/img/icons/iface-lag.svg | 5 + src/webui/static/img/icons/iface-veth.svg | 5 + src/webui/static/img/icons/iface-vlan.svg | 5 + src/webui/static/img/icons/iface-vxlan.svg | 3 + src/webui/static/img/icons/iface-wifi.svg | 6 + .../static/img/icons/iface-wireguard.svg | 5 + src/webui/static/img/icons/interfaces.svg | 3 + src/webui/static/img/icons/keystore.svg | 4 + src/webui/static/img/icons/lldp.svg | 7 + src/webui/static/img/icons/ntp.svg | 4 + src/webui/static/img/icons/routing.svg | 6 + src/webui/static/img/icons/status-down.svg | 5 + src/webui/static/img/icons/status-up.svg | 4 + src/webui/static/img/icons/status-warning.svg | 5 + src/webui/static/img/icons/wifi.svg | 6 + src/webui/static/img/icons/wireguard.svg | 5 + src/webui/static/img/jack.png | Bin 0 -> 1759 bytes src/webui/static/img/logo.png | Bin 0 -> 28727 bytes src/webui/static/js/app.js | 212 ++++ src/webui/static/js/htmx.min.js | 1 + .../templates/fragments/iface-counters.html | 45 + src/webui/templates/layouts/base.html | 36 + src/webui/templates/layouts/sidebar.html | 175 +++ src/webui/templates/pages/containers.html | 52 + src/webui/templates/pages/dashboard.html | 103 ++ src/webui/templates/pages/dhcp.html | 67 + src/webui/templates/pages/firewall.html | 122 ++ src/webui/templates/pages/firmware.html | 69 ++ src/webui/templates/pages/iface-detail.html | 155 +++ src/webui/templates/pages/interfaces.html | 46 + src/webui/templates/pages/keystore.html | 74 ++ src/webui/templates/pages/lldp.html | 45 + src/webui/templates/pages/login.html | 40 + src/webui/templates/pages/ntp.html | 74 ++ src/webui/templates/pages/routing.html | 113 ++ src/webui/templates/pages/vpn.html | 58 + src/webui/templates/pages/wifi.html | 148 +++ 106 files changed, 8830 insertions(+), 7 deletions(-) create mode 100644 board/common/rootfs/etc/default/webui create mode 120000 board/common/rootfs/etc/nginx/app/restconf.conf create mode 100644 board/common/rootfs/etc/nginx/restconf-access-local.conf create mode 120000 board/common/rootfs/etc/nginx/restconf-access.conf rename {board/common/rootfs/etc/nginx/available => package/landing}/default.conf (100%) create mode 100644 package/webui/50x.html create mode 100644 package/webui/Config.in create mode 100644 package/webui/default.conf create mode 100644 package/webui/webui.conf create mode 100644 package/webui/webui.mk create mode 100644 package/webui/webui.svc create mode 100644 src/webui/.gitignore create mode 100644 src/webui/LICENSE create mode 100644 src/webui/Makefile create mode 100644 src/webui/README.md create mode 100644 src/webui/go.mod create mode 100644 src/webui/internal/auth/login.go create mode 100644 src/webui/internal/auth/session.go create mode 100644 src/webui/internal/auth/store.go create mode 100644 src/webui/internal/handlers/capabilities.go create mode 100644 src/webui/internal/handlers/capabilities_test.go create mode 100644 src/webui/internal/handlers/common.go create mode 100644 src/webui/internal/handlers/containers.go create mode 100644 src/webui/internal/handlers/containers_test.go create mode 100644 src/webui/internal/handlers/dashboard.go create mode 100644 src/webui/internal/handlers/dashboard_test.go create mode 100644 src/webui/internal/handlers/dhcp.go create mode 100644 src/webui/internal/handlers/firewall.go create mode 100644 src/webui/internal/handlers/interfaces.go create mode 100644 src/webui/internal/handlers/keystore.go create mode 100644 src/webui/internal/handlers/lldp.go create mode 100644 src/webui/internal/handlers/ntp.go create mode 100644 src/webui/internal/handlers/routing.go create mode 100644 src/webui/internal/handlers/routing_test.go create mode 100644 src/webui/internal/handlers/services_test.go create mode 100644 src/webui/internal/handlers/system.go create mode 100644 src/webui/internal/handlers/vpn.go create mode 100644 src/webui/internal/handlers/vpn_test.go create mode 100644 src/webui/internal/handlers/wifi.go create mode 100644 src/webui/internal/handlers/wifi_test.go create mode 100644 src/webui/internal/restconf/client.go create mode 100644 src/webui/internal/restconf/errors.go create mode 100644 src/webui/internal/restconf/fetcher.go create mode 100644 src/webui/internal/security/csrf.go create mode 100644 src/webui/internal/server/middleware.go create mode 100644 src/webui/internal/server/server.go create mode 100644 src/webui/internal/testutil/helpers.go create mode 100644 src/webui/main.go create mode 100644 src/webui/static/css/style.css create mode 100644 src/webui/static/img/icons/containers.svg create mode 100644 src/webui/static/img/icons/dashboard.svg create mode 100644 src/webui/static/img/icons/dhcp.svg create mode 100644 src/webui/static/img/icons/firewall.svg create mode 100644 src/webui/static/img/icons/firmware.svg create mode 100644 src/webui/static/img/icons/iface-bridge.svg create mode 100644 src/webui/static/img/icons/iface-container.svg create mode 100644 src/webui/static/img/icons/iface-ethernet.svg create mode 100644 src/webui/static/img/icons/iface-gre.svg create mode 100644 src/webui/static/img/icons/iface-lag.svg create mode 100644 src/webui/static/img/icons/iface-veth.svg create mode 100644 src/webui/static/img/icons/iface-vlan.svg create mode 100644 src/webui/static/img/icons/iface-vxlan.svg create mode 100644 src/webui/static/img/icons/iface-wifi.svg create mode 100644 src/webui/static/img/icons/iface-wireguard.svg create mode 100644 src/webui/static/img/icons/interfaces.svg create mode 100644 src/webui/static/img/icons/keystore.svg create mode 100644 src/webui/static/img/icons/lldp.svg create mode 100644 src/webui/static/img/icons/ntp.svg create mode 100644 src/webui/static/img/icons/routing.svg create mode 100644 src/webui/static/img/icons/status-down.svg create mode 100644 src/webui/static/img/icons/status-up.svg create mode 100644 src/webui/static/img/icons/status-warning.svg create mode 100644 src/webui/static/img/icons/wifi.svg create mode 100644 src/webui/static/img/icons/wireguard.svg create mode 100644 src/webui/static/img/jack.png create mode 100644 src/webui/static/img/logo.png create mode 100644 src/webui/static/js/app.js create mode 100644 src/webui/static/js/htmx.min.js create mode 100644 src/webui/templates/fragments/iface-counters.html create mode 100644 src/webui/templates/layouts/base.html create mode 100644 src/webui/templates/layouts/sidebar.html create mode 100644 src/webui/templates/pages/containers.html create mode 100644 src/webui/templates/pages/dashboard.html create mode 100644 src/webui/templates/pages/dhcp.html create mode 100644 src/webui/templates/pages/firewall.html create mode 100644 src/webui/templates/pages/firmware.html create mode 100644 src/webui/templates/pages/iface-detail.html create mode 100644 src/webui/templates/pages/interfaces.html create mode 100644 src/webui/templates/pages/keystore.html create mode 100644 src/webui/templates/pages/lldp.html create mode 100644 src/webui/templates/pages/login.html create mode 100644 src/webui/templates/pages/ntp.html create mode 100644 src/webui/templates/pages/routing.html create mode 100644 src/webui/templates/pages/vpn.html create mode 100644 src/webui/templates/pages/wifi.html diff --git a/board/common/rootfs/etc/default/webui b/board/common/rootfs/etc/default/webui new file mode 100644 index 00000000..765ba175 --- /dev/null +++ b/board/common/rootfs/etc/default/webui @@ -0,0 +1,2 @@ +RESTCONF_URL=https://127.0.0.1/restconf +INSECURE_TLS=1 diff --git a/board/common/rootfs/etc/nginx/app/restconf.conf b/board/common/rootfs/etc/nginx/app/restconf.conf new file mode 120000 index 00000000..01182dc2 --- /dev/null +++ b/board/common/rootfs/etc/nginx/app/restconf.conf @@ -0,0 +1 @@ +../restconf.app \ No newline at end of file diff --git a/board/common/rootfs/etc/nginx/restconf-access-local.conf b/board/common/rootfs/etc/nginx/restconf-access-local.conf new file mode 100644 index 00000000..ec361e7d --- /dev/null +++ b/board/common/rootfs/etc/nginx/restconf-access-local.conf @@ -0,0 +1,3 @@ +allow 127.0.0.1; +allow ::1; +deny all; diff --git a/board/common/rootfs/etc/nginx/restconf-access.conf b/board/common/rootfs/etc/nginx/restconf-access.conf new file mode 120000 index 00000000..3a404cd8 --- /dev/null +++ b/board/common/rootfs/etc/nginx/restconf-access.conf @@ -0,0 +1 @@ +restconf-access-local.conf \ No newline at end of file diff --git a/board/common/rootfs/etc/nginx/restconf.app b/board/common/rootfs/etc/nginx/restconf.app index 7c8cadda..1d261741 100644 --- a/board/common/rootfs/etc/nginx/restconf.app +++ b/board/common/rootfs/etc/nginx/restconf.app @@ -1,5 +1,6 @@ # /telemetry/optics is for streaming (not used atm) location ~ ^/(restconf|yang|.well-known)/ { + include /etc/nginx/restconf-access.conf; grpc_pass grpc://[::1]:10080; grpc_set_header Host $host; grpc_set_header X-Real-IP $remote_addr; diff --git a/configs/aarch64_defconfig b/configs/aarch64_defconfig index d28e9ed5..447424ea 100644 --- a/configs/aarch64_defconfig +++ b/configs/aarch64_defconfig @@ -175,7 +175,6 @@ BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_IITO=y BR2_PACKAGE_KEYACK=y BR2_PACKAGE_KLISH_PLUGIN_INFIX=y -BR2_PACKAGE_LANDING=y BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y @@ -187,6 +186,8 @@ BR2_PACKAGE_PODMAN_DRIVER_DEVICEMAPPER=y BR2_PACKAGE_PODMAN_DRIVER_VFS=y BR2_PACKAGE_TETRIS=y BR2_PACKAGE_ROUSETTE=y +# BR2_PACKAGE_LANDING is not set +BR2_PACKAGE_WEBUI=y BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y BR2_PACKAGE_HOST_PYTHON_YANGDOC=y BR2_PACKAGE_PCIUTILS=y diff --git a/configs/aarch64_minimal_defconfig b/configs/aarch64_minimal_defconfig index 9ba5cb7c..c00579d0 100644 --- a/configs/aarch64_minimal_defconfig +++ b/configs/aarch64_minimal_defconfig @@ -144,6 +144,7 @@ BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_IITO=y BR2_PACKAGE_KEYACK=y BR2_PACKAGE_KLISH_PLUGIN_INFIX=y +BR2_PACKAGE_LANDING=y BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y diff --git a/configs/arm_defconfig b/configs/arm_defconfig index be928860..c08ba158 100644 --- a/configs/arm_defconfig +++ b/configs/arm_defconfig @@ -162,7 +162,6 @@ BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_IITO=y BR2_PACKAGE_KEYACK=y BR2_PACKAGE_KLISH_PLUGIN_INFIX=y -BR2_PACKAGE_LANDING=y BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y @@ -170,6 +169,8 @@ BR2_PACKAGE_NETBROWSE=y BR2_PACKAGE_ONIEPROM=y BR2_PACKAGE_TETRIS=y BR2_PACKAGE_ROUSETTE=y +# BR2_PACKAGE_LANDING is not set +BR2_PACKAGE_WEBUI=y BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y BR2_PACKAGE_HOST_PYTHON_YANGDOC=y IMAGE_ITB_AUX=y diff --git a/configs/arm_minimal_defconfig b/configs/arm_minimal_defconfig index d1cd8025..b9090d2f 100644 --- a/configs/arm_minimal_defconfig +++ b/configs/arm_minimal_defconfig @@ -142,6 +142,7 @@ BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_IITO=y BR2_PACKAGE_KEYACK=y BR2_PACKAGE_KLISH_PLUGIN_INFIX=y +BR2_PACKAGE_LANDING=y BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y diff --git a/configs/riscv64_defconfig b/configs/riscv64_defconfig index 30d59e69..b687676f 100644 --- a/configs/riscv64_defconfig +++ b/configs/riscv64_defconfig @@ -194,7 +194,6 @@ BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_IITO=y BR2_PACKAGE_KEYACK=y BR2_PACKAGE_KLISH_PLUGIN_INFIX=y -BR2_PACKAGE_LANDING=y BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y @@ -206,6 +205,8 @@ BR2_PACKAGE_PODMAN_DRIVER_DEVICEMAPPER=y BR2_PACKAGE_PODMAN_DRIVER_VFS=y BR2_PACKAGE_TETRIS=y BR2_PACKAGE_ROUSETTE=y +# BR2_PACKAGE_LANDING is not set +BR2_PACKAGE_WEBUI=y BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y BR2_PACKAGE_HOST_PYTHON_YANGDOC=y BR2_PACKAGE_PCIUTILS=y diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig index 903533b5..981cabf5 100644 --- a/configs/x86_64_defconfig +++ b/configs/x86_64_defconfig @@ -169,7 +169,6 @@ BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_IITO=y BR2_PACKAGE_KEYACK=y BR2_PACKAGE_KLISH_PLUGIN_INFIX=y -BR2_PACKAGE_LANDING=y BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y @@ -181,6 +180,8 @@ BR2_PACKAGE_PODMAN_DRIVER_DEVICEMAPPER=y BR2_PACKAGE_PODMAN_DRIVER_VFS=y BR2_PACKAGE_TETRIS=y BR2_PACKAGE_ROUSETTE=y +# BR2_PACKAGE_LANDING is not set +BR2_PACKAGE_WEBUI=y BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y BR2_PACKAGE_HOST_PYTHON_YANGDOC=y BR2_PACKAGE_PCIUTILS=y diff --git a/configs/x86_64_minimal_defconfig b/configs/x86_64_minimal_defconfig index 816cdd0f..893163ec 100644 --- a/configs/x86_64_minimal_defconfig +++ b/configs/x86_64_minimal_defconfig @@ -141,6 +141,7 @@ BR2_PACKAGE_FIREWALL=y BR2_PACKAGE_IITO=y BR2_PACKAGE_KEYACK=y BR2_PACKAGE_KLISH_PLUGIN_INFIX=y +BR2_PACKAGE_LANDING=y BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y diff --git a/package/Config.in b/package/Config.in index 110d247b..6998f580 100644 --- a/package/Config.in +++ b/package/Config.in @@ -42,6 +42,7 @@ source "$BR2_EXTERNAL_INFIX_PATH/package/tetris/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/libyang-cpp/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/sysrepo-cpp/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/rousette/Config.in" +source "$BR2_EXTERNAL_INFIX_PATH/package/webui/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/nghttp2-asio/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/date-cpp/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/rauc-installation-status/Config.in" diff --git a/board/common/rootfs/etc/nginx/available/default.conf b/package/landing/default.conf similarity index 100% rename from board/common/rootfs/etc/nginx/available/default.conf rename to package/landing/default.conf diff --git a/package/landing/landing.mk b/package/landing/landing.mk index 6a18d67c..076a8deb 100644 --- a/package/landing/landing.mk +++ b/package/landing/landing.mk @@ -14,6 +14,8 @@ define LANDING_INSTALL_TARGET_CMDS mkdir -p $(TARGET_DIR)/usr/html/ cp $(@D)/*.html $(TARGET_DIR)/usr/html/ cp $(@D)/*.png $(TARGET_DIR)/usr/html/ + $(INSTALL) -D -m 0644 $(LANDING_PKGDIR)/default.conf \ + $(TARGET_DIR)/etc/nginx/available/default.conf endef $(eval $(generic-package)) diff --git a/package/webui/50x.html b/package/webui/50x.html new file mode 100644 index 00000000..b2b334aa --- /dev/null +++ b/package/webui/50x.html @@ -0,0 +1,30 @@ + + + +Loading… + + + + + +

Loading…

+

The device is finishing its startup. This page refreshes automatically.

+

+ + diff --git a/package/webui/Config.in b/package/webui/Config.in new file mode 100644 index 00000000..cbf1a354 --- /dev/null +++ b/package/webui/Config.in @@ -0,0 +1,9 @@ +config BR2_PACKAGE_WEBUI + bool "webui" + depends on BR2_PACKAGE_HOST_GO_TARGET_ARCH_SUPPORTS + depends on BR2_PACKAGE_ROUSETTE + depends on !BR2_PACKAGE_LANDING + help + Web management interface for Infix, a Go+HTMX application + that provides browser-based configuration and monitoring + via RESTCONF. diff --git a/package/webui/default.conf b/package/webui/default.conf new file mode 100644 index 00000000..1e66a2e7 --- /dev/null +++ b/package/webui/default.conf @@ -0,0 +1,24 @@ +server { + listen 80; + listen [::]:80; + server_name _; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name _; + include ssl.conf; + + # 404 also points at /50x.html: the page is a "Loading…" screen + # with a meta-refresh, so the early-boot window where the Go + # backend isn't up yet (and any other transient 404 / 5xx) self- + # recovers as soon as upstream comes back. + error_page 404 500 502 503 504 /50x.html; + location = /50x.html { + root html; + } + + include /etc/nginx/app/*.conf; +} diff --git a/package/webui/webui.conf b/package/webui/webui.conf new file mode 100644 index 00000000..bcedda91 --- /dev/null +++ b/package/webui/webui.conf @@ -0,0 +1,9 @@ +location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; +} diff --git a/package/webui/webui.mk b/package/webui/webui.mk new file mode 100644 index 00000000..aaf49c2e --- /dev/null +++ b/package/webui/webui.mk @@ -0,0 +1,27 @@ +################################################################################ +# +# webui +# +################################################################################ + +WEBUI_VERSION = 1.0 +WEBUI_SITE_METHOD = local +WEBUI_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/webui +WEBUI_GOMOD = github.com/kernelkit/webui +WEBUI_LICENSE = MIT +WEBUI_LICENSE_FILES = LICENSE +WEBUI_REDISTRIBUTE = NO + +define WEBUI_INSTALL_EXTRA + $(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/webui.svc \ + $(FINIT_D)/available/webui.conf + $(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/webui.conf \ + $(TARGET_DIR)/etc/nginx/app/webui.conf + $(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/default.conf \ + $(TARGET_DIR)/etc/nginx/available/default.conf + $(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/50x.html \ + $(TARGET_DIR)/usr/html/50x.html +endef +WEBUI_POST_INSTALL_TARGET_HOOKS += WEBUI_INSTALL_EXTRA + +$(eval $(golang-package)) diff --git a/package/webui/webui.svc b/package/webui/webui.svc new file mode 100644 index 00000000..9a8979e3 --- /dev/null +++ b/package/webui/webui.svc @@ -0,0 +1,3 @@ +service name:webui log:prio:daemon.info,tag:webui \ + [2345] env:-/etc/default/webui webui -listen 127.0.0.1:10000 \ + -- Web management interface diff --git a/src/confd/src/services.c b/src/confd/src/services.c index 0981f80d..b1db7caf 100644 --- a/src/confd/src/services.c +++ b/src/confd/src/services.c @@ -19,6 +19,8 @@ #define GENERATE_STRING(STRING) #STRING, #define NGINX_SSL_CONF "/etc/nginx/ssl.conf" +#define NGINX_RESTCONF_ACCESS "/etc/nginx/restconf-access.conf" +#define NGINX_RESTCONF_ACCESS_LOCAL "restconf-access-local.conf" #define AVAHI_SVC_PATH "/etc/avahi/services" #define LLDP_CONFIG "/etc/lldpd.d/confd.conf" @@ -557,7 +559,30 @@ static int restconf_change(sr_session_ctx_t *session, struct lyd_node *config, s ena = lydx_is_enabled(srv, "enabled") && lydx_is_enabled(lydx_get_xpathf(config, WEB_XPATH), "enabled"); - svc_enable(ena, restconf, "restconf"); + + /* + * restconf.app is permanently installed in nginx/app/ so rousette is + * always reachable from loopback (required by the WebUI). External + * access is gated by an `include` of restconf-access.conf — empty + * means no allow/deny rules apply and nginx's default of `allow all` + * takes effect; populated means loopback-only. + * + * The shipped state is a symlink to restconf-access-local.conf (which + * holds the loopback rules), so the rules are defined in exactly one + * place. erase() before re-creating because fopen("w") on a symlink + * follows the link and would clobber the template; the empty-file + * path needs a real file, and the loopback path needs a fresh + * symlink that may have been replaced on a previous transition. + */ + erase(NGINX_RESTCONF_ACCESS); + if (ena) { + FILE *fp = fopen(NGINX_RESTCONF_ACCESS, "w"); + if (fp) + fclose(fp); + } else if (symlink(NGINX_RESTCONF_ACCESS_LOCAL, NGINX_RESTCONF_ACCESS)) { + ERRNO("failed restoring %s", NGINX_RESTCONF_ACCESS); + } + mdns_records(ena ? MDNS_ADD : MDNS_DELETE, restconf); finit_reload("nginx"); return put(cfg); @@ -703,13 +728,16 @@ static int web_change(sr_session_ctx_t *session, struct lyd_node *config, struct /* Web master on/off: propagate to nginx and all sub-services */ if (lydx_get_xpathf(diff, WEB_XPATH "/enabled")) { + int rc_ena = ena && lydx_is_enabled(lydx_get_xpathf(config, WEB_RESTCONF_XPATH), "enabled"); int nb_ena = ena && lydx_is_enabled(lydx_get_xpathf(config, WEB_NETBROWSE_XPATH), "enabled"); svc_enable(ena && lydx_is_enabled(lydx_get_xpathf(config, WEB_CONSOLE_XPATH), "enabled"), ttyd, "ttyd"); svc_enable(nb_ena, netbrowse, "netbrowse"); - svc_enable(ena && lydx_is_enabled(lydx_get_xpathf(config, WEB_RESTCONF_XPATH), "enabled"), - restconf, "restconf"); + /* Rousette follows web/enabled; external access is gated separately via restconf/enabled */ + ena ? finit_enable("restconf") : finit_disable("restconf"); + ena ? finit_enable("webui") : finit_disable("webui"); + mdns_records(rc_ena ? MDNS_ADD : MDNS_DELETE, restconf); svc_enable(ena, web, "nginx"); mdns_alias_conf(nb_ena); finit_reload("mdns-alias"); diff --git a/src/webui/.gitignore b/src/webui/.gitignore new file mode 100644 index 00000000..859dc423 --- /dev/null +++ b/src/webui/.gitignore @@ -0,0 +1 @@ +webui diff --git a/src/webui/LICENSE b/src/webui/LICENSE new file mode 100644 index 00000000..364389fb --- /dev/null +++ b/src/webui/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2026 The KernelKit Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/webui/Makefile b/src/webui/Makefile new file mode 100644 index 00000000..e21f50dd --- /dev/null +++ b/src/webui/Makefile @@ -0,0 +1,15 @@ +BINARY = webui +GOARCH ?= $(shell go env GOARCH) +GOOS ?= $(shell go env GOOS) + +build: + CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \ + go build -ldflags="-s -w" -o $(BINARY) . + +dev: build + go run . --listen :10000 --session-key /tmp/webui-session.key --insecure-tls $(ARGS) + +clean: + rm -f $(BINARY) + +.PHONY: build dev clean diff --git a/src/webui/README.md b/src/webui/README.md new file mode 100644 index 00000000..d847f94b --- /dev/null +++ b/src/webui/README.md @@ -0,0 +1,120 @@ +# Infix WebUI + +A lightweight web management interface for [Infix][1] network devices, +built with Go and [htmx][2]. + +The WebUI communicates with the device over [RESTCONF][3] (RFC 8040), +presenting the same operational data available through the Infix CLI in +a browser-friendly format. + +## Features + +- **Dashboard** -- system info, hardware, sensors, and interface summary + with bridge member grouping +- **Interfaces** -- list with status, addresses, and per-type detail; + click through to a detail page with live-updating counters, WiFi + station table, scan results, WireGuard peers, and ethernet frame + statistics +- **Firewall** -- zone-to-zone policy matrix +- **Keystore** -- symmetric and asymmetric key display +- **Firmware** -- slot overview, install from URL with live progress +- **Reboot** -- two-phase status polling (wait down, wait up) +- **Config download** -- startup datastore as JSON + + +## Building + +Requires Go 1.22 or later. + +```sh +make build +``` + +Produces a statically linked `webui` binary with all templates, +CSS, and JS embedded. + +Cross-compile for the target: + +```sh +GOOS=linux GOARCH=arm64 make build +``` + + +## Running + +```sh +./webui --restconf https://192.168.0.1/restconf --listen :10000 +``` + +| **Flag** | **Default** | **Description** | +|-------------------|-----------------------------------|-------------------------------------------| +| `--listen` | `:10000` | Address to listen on | +| `--restconf` | `http://localhost:8080/restconf` | RESTCONF base URL of the device | +| `--session-key` | `/var/lib/misc/webui-session.key` | Path to persistent session encryption key | +| `--insecure-tls` | `false` | Disable TLS certificate verification | + +The RESTCONF URL can also be set via the `RESTCONF_URL` environment +variable. + + +## Development + +Point `RESTCONF_URL` at a running Infix device and start the dev +server: + +```sh +make dev ARGS="--restconf https://192.168.0.1/restconf" +``` + +This runs `go run .` on port 10000 with `--insecure-tls` already set. + + +## Architecture + +``` +Browser ──htmx──▶ Go server ──RESTCONF──▶ Infix device (rousette/sysrepo) +``` + +- **Single binary** -- templates, CSS, JS, and images are embedded via + `go:embed` +- **Server-side rendering** -- Go `html/template` with per-page parsing + to avoid `{{define "content"}}` collisions +- **htmx SPA navigation** -- sidebar links use `hx-get` / `hx-target` + for partial page updates with `hx-push-url` for browser history +- **Stateless sessions** -- AES-256-GCM encrypted cookies carry + credentials (needed for every RESTCONF call); no server-side session + store +- **Live polling** -- counters update every 5s, firmware progress every + 3s, all via htmx triggers + +``` +main.go Entry point, flags, embedded FS +internal/ + auth/ Login, logout, session (AES-GCM cookies) + restconf/ HTTP client (Get, GetRaw, Post, PostJSON) + handlers/ Page handlers + dashboard.go Dashboard, hardware, sensors + interfaces.go Interface list, detail, counters + firewall.go Zone matrix + keystore.go Key display + system.go Firmware, reboot, config download + server/ + server.go Route registration, template wiring, middleware +templates/ + layouts/ base.html (shell), sidebar.html + pages/ Per-page templates (one per route) + fragments/ htmx partial fragments +static/ + css/style.css All styles + js/htmx.min.js htmx library + img/ Logo, favicon +``` + + +## License + +See [LICENSE](LICENSE). + +[1]: https://github.com/kernelkit/infix +[2]: https://htmx.org +[3]: https://datatracker.ietf.org/doc/html/rfc8040 diff --git a/src/webui/go.mod b/src/webui/go.mod new file mode 100644 index 00000000..63966d74 --- /dev/null +++ b/src/webui/go.mod @@ -0,0 +1,3 @@ +module github.com/kernelkit/webui + +go 1.22 diff --git a/src/webui/internal/auth/login.go b/src/webui/internal/auth/login.go new file mode 100644 index 00000000..17d75f79 --- /dev/null +++ b/src/webui/internal/auth/login.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "html/template" + "log" + "net/http" + + "github.com/kernelkit/webui/internal/handlers" + "github.com/kernelkit/webui/internal/restconf" + "github.com/kernelkit/webui/internal/security" +) + +const cookieName = "session" + +// LoginHandler serves the login page and processes login/logout requests. +type LoginHandler struct { + Store *SessionStore + RC *restconf.Client + Template *template.Template +} + +type loginData struct { + Error string + CsrfToken string +} + +// ShowLogin renders the login page (GET /login). +func (h *LoginHandler) ShowLogin(w http.ResponseWriter, r *http.Request) { + h.renderLogin(w, r, "") +} + +// DoLogin validates credentials against RESTCONF and creates a session (POST /login). +func (h *LoginHandler) DoLogin(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + h.renderLogin(w, r, "Invalid request.") + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + + if username == "" || password == "" { + h.renderLogin(w, r, "Username and password are required.") + return + } + + // Verify credentials by making a RESTCONF call with Basic Auth. + err := h.RC.CheckAuth(username, password) + if err != nil { + log.Printf("login failed for %q: %v", username, err) + var authErr *restconf.AuthError + if errors.As(err, &authErr) { + h.renderLogin(w, r, "Invalid username or password.") + } else { + h.renderLogin(w, r, "Unable to reach the device. Please try again later.") + } + return + } + + // Probe optional features once at login and bake into the session. + ctx := restconf.ContextWithCredentials(r.Context(), restconf.Credentials{ + Username: username, + Password: password, + }) + caps := handlers.DetectCapabilities(ctx, h.RC) + + token, csrfToken, err := h.Store.Create(username, password, caps.Features()) + if err != nil { + log.Printf("session create error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: security.IsSecureRequest(r), + SameSite: http.SameSiteLaxMode, + }) + security.EnsureToken(w, r, csrfToken) + + fullRedirect(w, r, "/") +} + +// DoLogout destroys the session and redirects to the login page (POST /logout). +func (h *LoginHandler) DoLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(cookieName); err == nil { + h.Store.Delete(c.Value) + } + + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: security.IsSecureRequest(r), + SameSite: http.SameSiteLaxMode, + }) + security.ClearToken(w, r) + + fullRedirect(w, r, "/login") +} + +// fullRedirect forces a full page navigation. When the request comes +// from htmx (boosted form) we use HX-Redirect so the browser does a +// real page load instead of an AJAX swap — this is essential for the +// login/logout transition where the page layout changes completely. +func fullRedirect(w http.ResponseWriter, r *http.Request, url string) { + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", url) + return + } + http.Redirect(w, r, url, http.StatusSeeOther) +} + +func (h *LoginHandler) renderLogin(w http.ResponseWriter, r *http.Request, errMsg string) { + data := loginData{ + Error: errMsg, + CsrfToken: security.TokenFromContext(r.Context()), + } + if err := h.Template.ExecuteTemplate(w, "login.html", data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} diff --git a/src/webui/internal/auth/session.go b/src/webui/internal/auth/session.go new file mode 100644 index 00000000..f8d87aab --- /dev/null +++ b/src/webui/internal/auth/session.go @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: MIT + +package auth diff --git a/src/webui/internal/auth/store.go b/src/webui/internal/auth/store.go new file mode 100644 index 00000000..25051ce9 --- /dev/null +++ b/src/webui/internal/auth/store.go @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT + +package auth + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +const sessionTimeout = 1 * time.Hour + +type tokenPayload struct { + Username string `json:"u"` + Password string `json:"p"` + CsrfToken string `json:"c"` + CreatedAt int64 `json:"t"` + Features map[string]bool `json:"f,omitempty"` +} + +// SessionStore issues and validates stateless encrypted tokens. +// The cookie value is a base64url-encoded AES-256-GCM sealed blob +// containing the user's credentials and a creation timestamp. +// No server-side session map is needed — only the AES key must +// persist across restarts. +type SessionStore struct { + aead cipher.AEAD +} + +// NewSessionStore creates a store. If keyFile is non-empty, the AES +// key is read from that path (or generated and written there on first +// run). If keyFile is empty, a random ephemeral key is used. +func NewSessionStore(keyFile string) (*SessionStore, error) { + key, err := loadOrCreateKey(keyFile) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + return &SessionStore{aead: aead}, nil +} + +// Create returns an encrypted token carrying the user's credentials and capabilities. +func (s *SessionStore) Create(username, password string, features map[string]bool) (string, string, error) { + csrf := randomToken() + token, err := s.CreateWithCSRF(username, password, csrf, features) + return token, csrf, err +} + +// CreateWithCSRF returns an encrypted token carrying the user's credentials, +// capabilities, and a bound CSRF token. +func (s *SessionStore) CreateWithCSRF(username, password, csrf string, features map[string]bool) (string, error) { + payload, err := json.Marshal(tokenPayload{ + Username: username, + Password: password, + CsrfToken: csrf, + CreatedAt: time.Now().Unix(), + Features: features, + }) + if err != nil { + return "", err + } + + nonce := make([]byte, s.aead.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + sealed := s.aead.Seal(nonce, nonce, payload, nil) + return base64.RawURLEncoding.EncodeToString(sealed), nil +} + +// Lookup decrypts a token and returns the credentials and capabilities if valid. +func (s *SessionStore) Lookup(token string) (username, password, csrf string, features map[string]bool, ok bool) { + raw, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return "", "", "", nil, false + } + + ns := s.aead.NonceSize() + if len(raw) < ns { + return "", "", "", nil, false + } + + plaintext, err := s.aead.Open(nil, raw[:ns], raw[ns:], nil) + if err != nil { + return "", "", "", nil, false + } + + var p tokenPayload + if err := json.Unmarshal(plaintext, &p); err != nil { + return "", "", "", nil, false + } + + if time.Since(time.Unix(p.CreatedAt, 0)) > sessionTimeout { + return "", "", "", nil, false + } + + return p.Username, p.Password, p.CsrfToken, p.Features, true +} + +// Delete is a no-op for stateless tokens (the cookie is cleared by +// the caller), but kept to satisfy the existing logout flow. +func (s *SessionStore) Delete(token string) {} + +// loadOrCreateKey returns a 32-byte AES key. When path is non-empty +// the key is persisted so sessions survive restarts. +func loadOrCreateKey(path string) ([32]byte, error) { + var key [32]byte + + if path != "" { + data, err := os.ReadFile(path) + if err == nil && len(data) == 32 { + copy(key[:], data) + return key, nil + } + } + + if _, err := io.ReadFull(rand.Reader, key[:]); err != nil { + return key, fmt.Errorf("generate session key: %w", err) + } + + if path != "" { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return key, fmt.Errorf("create key directory: %w", err) + } + if err := os.WriteFile(path, key[:], 0600); err != nil { + return key, fmt.Errorf("write session key: %w", err) + } + } + + return key, nil +} + +func randomToken() string { + var b [32]byte + if _, err := io.ReadFull(rand.Reader, b[:]); err != nil { + return "" + } + return base64.RawURLEncoding.EncodeToString(b[:]) +} diff --git a/src/webui/internal/handlers/capabilities.go b/src/webui/internal/handlers/capabilities.go new file mode 100644 index 00000000..5472894b --- /dev/null +++ b/src/webui/internal/handlers/capabilities.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "log" + + "github.com/kernelkit/webui/internal/restconf" +) + +type feature struct { + Name string // key used in Has() and session cookie + Module string // YANG module that carries the feature + Feature string // YANG feature name in the module's feature array +} + +// optionalFeatures maps UI capabilities to YANG module features. Extend here. +var optionalFeatures = []feature{ + {Name: "wifi", Module: "infix-interfaces", Feature: "wifi"}, + {Name: "containers", Module: "infix-interfaces", Feature: "containers"}, +} + +// Capabilities tracks which optional features are present on the device. +// Use Has("feature-name") in templates and Go code. +type Capabilities struct { + features map[string]bool +} + +func NewCapabilities(features map[string]bool) *Capabilities { + if features == nil { + features = make(map[string]bool) + } + return &Capabilities{features: features} +} + +func (c *Capabilities) Has(name string) bool { + return c != nil && c.features[name] +} + +func (c *Capabilities) Features() map[string]bool { + if c == nil { + return nil + } + return c.features +} + +type capsCtxKey struct{} + +func ContextWithCapabilities(ctx context.Context, caps *Capabilities) context.Context { + return context.WithValue(ctx, capsCtxKey{}, caps) +} + +func CapabilitiesFromContext(ctx context.Context) *Capabilities { + caps, _ := ctx.Value(capsCtxKey{}).(*Capabilities) + if caps == nil { + return NewCapabilities(nil) + } + return caps +} + +type yangLibrary struct { + YangLibrary struct { + ModuleSet []struct { + Module []struct { + Name string `json:"name"` + Feature []string `json:"feature"` + } `json:"module"` + } `json:"module-set"` + } `json:"ietf-yang-library:yang-library"` +} + +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 { + log.Printf("yang-library: %v (ignored, no optional features)", err) + return NewCapabilities(nil) + } + + // Build index: module name → set of YANG features advertised. + modFeatures := make(map[string]map[string]bool) + for _, ms := range lib.YangLibrary.ModuleSet { + for _, m := range ms.Module { + fs := make(map[string]bool, len(m.Feature)) + for _, f := range m.Feature { + fs[f] = true + } + modFeatures[m.Name] = fs + } + } + + result := make(map[string]bool, len(optionalFeatures)) + for _, f := range optionalFeatures { + result[f.Name] = modFeatures[f.Module][f.Feature] + } + + return NewCapabilities(result) +} diff --git a/src/webui/internal/handlers/capabilities_test.go b/src/webui/internal/handlers/capabilities_test.go new file mode 100644 index 00000000..18841492 --- /dev/null +++ b/src/webui/internal/handlers/capabilities_test.go @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "errors" + "testing" + + "github.com/kernelkit/webui/internal/testutil" +) + +func yangLibraryResponse(modules ...map[string]interface{}) map[string]interface{} { + mods := make([]interface{}, len(modules)) + for i, m := range modules { + mods[i] = m + } + return map[string]interface{}{ + "ietf-yang-library:yang-library": map[string]interface{}{ + "module-set": []interface{}{ + map[string]interface{}{"module": mods}, + }, + }, + } +} + +func module(name string, features ...string) map[string]interface{} { + m := map[string]interface{}{"name": name} + if len(features) > 0 { + m["feature"] = features + } + return m +} + +func TestDetectCapabilities_NoModules(t *testing.T) { + mock := testutil.NewMockFetcher() + mock.SetResponse("/data/ietf-yang-library:yang-library", yangLibraryResponse()) + + caps := DetectCapabilities(context.Background(), mock) + if caps.Has("wifi") || caps.Has("containers") { + t.Errorf("expected no features, got wifi=%v containers=%v", + caps.Has("wifi"), caps.Has("containers")) + } +} + +func TestDetectCapabilities_YangLibraryError(t *testing.T) { + mock := testutil.NewMockFetcher() + mock.SetError("/data/ietf-yang-library:yang-library", errors.New("unreachable")) + + caps := DetectCapabilities(context.Background(), mock) + if caps.Has("wifi") || caps.Has("containers") { + t.Errorf("expected no features on error, got wifi=%v containers=%v", + caps.Has("wifi"), caps.Has("containers")) + } +} + +func TestDetectCapabilities_ContainersModule(t *testing.T) { + mock := testutil.NewMockFetcher() + mock.SetResponse("/data/ietf-yang-library:yang-library", + yangLibraryResponse( + module("ietf-interfaces"), + module("infix-interfaces", "vlan-filtering", "containers"), + )) + + caps := DetectCapabilities(context.Background(), mock) + if !caps.Has("containers") { + t.Error("expected containers=true") + } + if caps.Has("wifi") { + t.Error("expected wifi=false") + } +} + +func TestDetectCapabilities_WiFiModule(t *testing.T) { + mock := testutil.NewMockFetcher() + mock.SetResponse("/data/ietf-yang-library:yang-library", + yangLibraryResponse( + module("ietf-interfaces"), + module("infix-interfaces", "vlan-filtering", "wifi"), + )) + + caps := DetectCapabilities(context.Background(), mock) + if !caps.Has("wifi") { + t.Error("expected wifi=true") + } + if caps.Has("containers") { + t.Error("expected containers=false") + } +} + +func TestDetectCapabilities_BothModules(t *testing.T) { + mock := testutil.NewMockFetcher() + mock.SetResponse("/data/ietf-yang-library:yang-library", + yangLibraryResponse( + module("ietf-interfaces"), + module("infix-interfaces", "vlan-filtering", "containers", "wifi"), + )) + + caps := DetectCapabilities(context.Background(), mock) + if !caps.Has("wifi") || !caps.Has("containers") { + t.Errorf("expected both features, got wifi=%v containers=%v", + caps.Has("wifi"), caps.Has("containers")) + } +} + +func TestCapabilitiesFromContext_NilReturnsEmpty(t *testing.T) { + caps := CapabilitiesFromContext(context.Background()) + if caps == nil { + t.Fatal("expected non-nil Capabilities") + } + if caps.Has("wifi") || caps.Has("containers") { + t.Error("expected no features for empty context") + } +} + +func TestCapabilitiesFromContext_RoundTrip(t *testing.T) { + orig := NewCapabilities(map[string]bool{"wifi": true, "containers": true}) + ctx := ContextWithCapabilities(context.Background(), orig) + got := CapabilitiesFromContext(ctx) + if !got.Has("wifi") || !got.Has("containers") { + t.Errorf("expected wifi=true containers=true, got wifi=%v containers=%v", + got.Has("wifi"), got.Has("containers")) + } +} diff --git a/src/webui/internal/handlers/common.go b/src/webui/internal/handlers/common.go new file mode 100644 index 00000000..74018bc5 --- /dev/null +++ b/src/webui/internal/handlers/common.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + + "github.com/kernelkit/webui/internal/security" +) + +// PageData is the base template data passed to every page. +type PageData struct { + CsrfToken string + PageTitle string + ActivePage string + Capabilities *Capabilities +} + +func csrfToken(ctx context.Context) string { + return security.TokenFromContext(ctx) +} diff --git a/src/webui/internal/handlers/containers.go b/src/webui/internal/handlers/containers.go new file mode 100644 index 00000000..19807383 --- /dev/null +++ b/src/webui/internal/handlers/containers.go @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "sync" + + "github.com/kernelkit/webui/internal/restconf" +) + +// containerJSON matches the RESTCONF JSON for a single container entry. +type containerJSON struct { + Name string `json:"name"` + Image string `json:"image"` + Running yangBool `json:"running"` + Status string `json:"status"` + Network struct { + Publish []string `json:"publish"` + } `json:"network"` + ResourceUsage containerResourceUsageJSON `json:"resource-usage"` + ResourceLimit containerResourceLimitJSON `json:"resource-limit"` +} + +// containerResourceUsageJSON matches the RESTCONF JSON for resource-usage. +type containerResourceUsageJSON struct { + Memory yangInt64 `json:"memory"` // KiB + CPU yangFloat64 `json:"cpu"` // percent +} + +// containerResourceLimitJSON matches the RESTCONF JSON for resource-limit. +type containerResourceLimitJSON struct { + Memory yangInt64 `json:"memory"` // KiB +} + +// containerListWrapper wraps the top-level RESTCONF containers response. +// The server returns the full "containers" object; the list lives inside it. +type containerListWrapper struct { + Containers struct { + Container []containerJSON `json:"container"` + } `json:"infix-containers:containers"` +} + +// containerResourceUsageWrapper wraps the RESTCONF resource-usage response. +type containerResourceUsageWrapper struct { + ResourceUsage containerResourceUsageJSON `json:"infix-containers:resource-usage"` +} + +// ContainerEntry holds display-ready data for a single container row. +type ContainerEntry struct { + Name string + Image string + Status string + Running bool + CPUPct int + MemUsed string + MemLimit string + MemPct int + Uptime string + Ports []string +} + +// containersData is the template data for the containers page. +type containersData struct { + CsrfToken string + PageTitle string + ActivePage string + Capabilities *Capabilities + Containers []ContainerEntry + Error string +} + +// ContainersHandler serves the containers status page. +type ContainersHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the containers list page. +func (h *ContainersHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := containersData{ + CsrfToken: csrfToken(r.Context()), + PageTitle: "Containers", + ActivePage: "containers", + Capabilities: CapabilitiesFromContext(r.Context()), + } + + // Detach from the request context so that RESTCONF calls survive + // browser connection resets. + ctx := context.WithoutCancel(r.Context()) + + var listResp containerListWrapper + if err := h.RC.Get(ctx, "/data/infix-containers:containers", &listResp); err != nil { + log.Printf("restconf containers list: %v", err) + data.Error = "Could not fetch container information" + } else { + containers := listResp.Containers.Container + + // Fetch resource-usage for each container concurrently. + usages := make([]containerResourceUsageJSON, len(containers)) + var mu sync.Mutex + var wg sync.WaitGroup + + for i, c := range containers { + wg.Add(1) + go func(idx int, name string) { + defer wg.Done() + path := fmt.Sprintf("/data/infix-containers:containers/container=%s/resource-usage", + url.PathEscape(name)) + var w containerResourceUsageWrapper + if err := h.RC.Get(ctx, path, &w); err != nil { + log.Printf("restconf resource-usage %s: %v", name, err) + return + } + mu.Lock() + usages[idx] = w.ResourceUsage + mu.Unlock() + }(i, c.Name) + } + wg.Wait() + + for i, c := range containers { + entry := ContainerEntry{ + Name: c.Name, + Image: c.Image, + Status: c.Status, + Running: bool(c.Running), + Ports: c.Network.Publish, + } + + // CPU usage — round to int. + entry.CPUPct = int(float64(usages[i].CPU) + 0.5) + if entry.CPUPct > 100 { + entry.CPUPct = 100 + } + + // Memory usage — resource-usage.memory is in KiB. + memUsedKiB := int64(usages[i].Memory) + if memUsedKiB > 0 { + entry.MemUsed = humanBytes(memUsedKiB * 1024) + } + + // Memory limit — resource-limit.memory is in KiB. + memLimitKiB := int64(c.ResourceLimit.Memory) + if memLimitKiB > 0 { + entry.MemLimit = humanBytes(memLimitKiB * 1024) + if memUsedKiB > 0 { + entry.MemPct = int(float64(memUsedKiB) / float64(memLimitKiB) * 100) + if entry.MemPct > 100 { + entry.MemPct = 100 + } + } + } + + // Uptime: extract from status string (e.g., "Up About a minute", "Up 3 hours"). + entry.Uptime = extractUptime(c.Status) + + data.Containers = append(data.Containers, entry) + } + } + + tmplName := "containers.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// extractUptime returns the uptime portion of a container status string. +// E.g., "Up About a minute" → "About a minute", "Up 3 hours" → "3 hours", +// "Exited (0) 2 hours ago" → "". +func extractUptime(status string) string { + const prefix = "Up " + if len(status) > len(prefix) && status[:len(prefix)] == prefix { + return status[len(prefix):] + } + return "" +} diff --git a/src/webui/internal/handlers/containers_test.go b/src/webui/internal/handlers/containers_test.go new file mode 100644 index 00000000..1f3ec95e --- /dev/null +++ b/src/webui/internal/handlers/containers_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "html/template" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kernelkit/webui/internal/restconf" + "github.com/kernelkit/webui/internal/security" +) + +var minimalContainersTmpl = template.Must(template.New("containers.html").Parse( + `{{define "containers.html"}}count={{len .Containers}}{{end}}` + + `{{define "content"}}{{len .Containers}}{{end}}`, +)) + +func TestContainersOverview_ReturnsOK(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &ContainersHandler{Template: minimalContainersTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/containers", nil) + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body") + } +} + +func TestContainersOverview_HTMXPartial(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &ContainersHandler{Template: minimalContainersTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/containers", nil) + req.Header.Set("HX-Request", "true") + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body for htmx partial") + } +} diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go new file mode 100644 index 00000000..de0f2248 --- /dev/null +++ b/src/webui/internal/handlers/dashboard.go @@ -0,0 +1,469 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "log" + "math" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/kernelkit/webui/internal/restconf" +) + +// yangInt64 unmarshals a YANG numeric value that RESTCONF encodes as a +// JSON string (e.g. "1024000") or, occasionally, as a bare number. +type yangInt64 int64 + +func (y *yangInt64) UnmarshalJSON(b []byte) error { + var s string + if json.Unmarshal(b, &s) == nil { + v, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return err + } + *y = yangInt64(v) + return nil + } + var v int64 + if err := json.Unmarshal(b, &v); err != nil { + return err + } + *y = yangInt64(v) + return nil +} + +// yangBool unmarshals a YANG boolean that RESTCONF may encode as a +// JSON string ("true"/"false") or as a bare boolean. +type yangBool bool + +func (y *yangBool) UnmarshalJSON(b []byte) error { + var s string + if json.Unmarshal(b, &s) == nil { + v, err := strconv.ParseBool(s) + if err != nil { + return err + } + *y = yangBool(v) + return nil + } + var v bool + if err := json.Unmarshal(b, &v); err != nil { + return err + } + *y = yangBool(v) + return nil +} + +// yangFloat64 unmarshals a YANG decimal value that RESTCONF may encode +// as a JSON string (e.g. "0.12") or as a bare number. +type yangFloat64 float64 + +func (y *yangFloat64) UnmarshalJSON(b []byte) error { + var s string + if json.Unmarshal(b, &s) == nil { + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return err + } + *y = yangFloat64(v) + return nil + } + var v float64 + if err := json.Unmarshal(b, &v); err != nil { + return err + } + *y = yangFloat64(v) + return nil +} + +// RESTCONF JSON structures for ietf-system:system-state. + +type systemStateWrapper struct { + SystemState systemState `json:"ietf-system:system-state"` +} + +type systemState struct { + Platform platform `json:"platform"` + Clock clock `json:"clock"` + Software software `json:"infix-system:software"` + Resource resourceUsage `json:"infix-system:resource-usage"` +} + +type platform struct { + OSName string `json:"os-name"` + OSVersion string `json:"os-version"` + Machine string `json:"machine"` +} + +type clock struct { + BootDatetime string `json:"boot-datetime"` + CurrentDatetime string `json:"current-datetime"` +} + +type software struct { + Booted string `json:"booted"` + Slot []softwareSlot `json:"slot"` +} + +type softwareSlot struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type resourceUsage struct { + Memory memoryInfo `json:"memory"` + LoadAverage loadAverage `json:"load-average"` + Filesystem []filesystemFS `json:"filesystem"` +} + +type memoryInfo struct { + Total yangInt64 `json:"total"` + Free yangInt64 `json:"free"` + Available yangInt64 `json:"available"` +} + +type loadAverage struct { + Load1min yangFloat64 `json:"load-1min"` + Load5min yangFloat64 `json:"load-5min"` + Load15min yangFloat64 `json:"load-15min"` +} + +type filesystemFS struct { + MountPoint string `json:"mount-point"` + Size yangInt64 `json:"size"` + Used yangInt64 `json:"used"` + Available yangInt64 `json:"available"` +} + +// RESTCONF JSON structures for ietf-hardware:hardware. + +type hardwareWrapper struct { + Hardware struct { + Component []hwComponentJSON `json:"component"` + } `json:"ietf-hardware:hardware"` +} + +type hwComponentJSON struct { + Name string `json:"name"` + Class string `json:"class"` + Description string `json:"description"` + Parent string `json:"parent"` + MfgName string `json:"mfg-name"` + ModelName string `json:"model-name"` + SerialNum string `json:"serial-num"` + HardwareRev string `json:"hardware-rev"` + PhysAddress string `json:"infix-hardware:phys-address"` + WiFiRadio *wifiRadioJSON `json:"infix-hardware:wifi-radio"` + SensorData *struct { + ValueType string `json:"value-type"` + Value yangInt64 `json:"value"` + ValueScale string `json:"value-scale"` + OperStatus string `json:"oper-status"` + } `json:"sensor-data"` + State *struct { + AdminState string `json:"admin-state"` + OperState string `json:"oper-state"` + } `json:"state"` +} + +// Template data structures. + +type dashboardData struct { + CsrfToken string + Username string + ActivePage string + PageTitle string + Capabilities *Capabilities + Hostname string + OSName string + OSVersion string + Machine string + Firmware string + Uptime string + MemTotal int64 + MemUsed int64 + MemPercent int + MemClass string + Load1 string + Load5 string + Load15 string + CPUClass string + Disks []diskEntry + Board boardInfo + Sensors []sensorEntry + Error string +} + +type boardInfo struct { + Model string + Manufacturer string + SerialNum string + HardwareRev string + BaseMAC string +} + +type sensorEntry struct { + Name string + Value string + Type string // "temperature", "fan", "voltage", etc. +} + +type diskEntry struct { + Mount string + Size string + Used string + Available string + Percent int +} + +// DashboardHandler serves the main dashboard page. +type DashboardHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Index renders the dashboard (GET /). +func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { + creds := restconf.CredentialsFromContext(r.Context()) + data := dashboardData{ + Username: creds.Username, + CsrfToken: csrfToken(r.Context()), + ActivePage: "dashboard", + PageTitle: "Dashboard", + Capabilities: CapabilitiesFromContext(r.Context()), + } + + // Detach from the request context so that RESTCONF calls survive + // browser connection resets (common during login redirects). + // The RESTCONF client's own 10 s timeout still bounds each call. + ctx := context.WithoutCancel(r.Context()) + var ( + state systemStateWrapper + hw hardwareWrapper + sysConf struct { + System struct { + Hostname string `json:"hostname"` + } `json:"ietf-system:system"` + } + stateErr, hwErr, confErr error + wg sync.WaitGroup + ) + + wg.Add(3) + go func() { + defer wg.Done() + stateErr = h.RC.Get(ctx, "/data/ietf-system:system-state", &state) + }() + go func() { + defer wg.Done() + hwErr = h.RC.Get(ctx, "/data/ietf-hardware:hardware", &hw) + }() + go func() { + defer wg.Done() + confErr = h.RC.Get(ctx, "/data/ietf-system:system", &sysConf) + }() + wg.Wait() + + if stateErr != nil { + log.Printf("restconf system-state: %v", stateErr) + data.Error = "Could not fetch system information" + } else { + ss := state.SystemState + data.OSName = ss.Platform.OSName + data.OSVersion = ss.Platform.OSVersion + data.Machine = ss.Platform.Machine + data.Firmware = firmwareVersion(ss.Software) + data.Uptime = computeUptime(ss.Clock.BootDatetime, ss.Clock.CurrentDatetime) + + total := int64(ss.Resource.Memory.Total) + avail := int64(ss.Resource.Memory.Available) + data.MemTotal = total / 1024 // KiB → MiB + data.MemUsed = (total - avail) / 1024 + if total > 0 { + data.MemPercent = int(float64(total-avail) / float64(total) * 100) + } + + switch { + case data.MemPercent >= 90: + data.MemClass = "is-crit" + case data.MemPercent >= 70: + data.MemClass = "is-warn" + default: + data.MemClass = "" + } + + la := ss.Resource.LoadAverage + if la1 := float64(la.Load1min); la1 >= 0.9 { + data.CPUClass = "is-crit" + } else if la1 >= 0.7 { + data.CPUClass = "is-warn" + } + + data.Load1 = strconv.FormatFloat(float64(la.Load1min), 'f', 2, 64) + data.Load5 = strconv.FormatFloat(float64(la.Load5min), 'f', 2, 64) + data.Load15 = strconv.FormatFloat(float64(la.Load15min), 'f', 2, 64) + + for _, fs := range ss.Resource.Filesystem { + size := int64(fs.Size) + used := int64(fs.Used) + pct := 0 + if size > 0 { + pct = int(float64(used) / float64(size) * 100) + } + data.Disks = append(data.Disks, diskEntry{ + Mount: fs.MountPoint, + Size: humanKiB(size), + Used: humanKiB(used), + Available: humanKiB(int64(fs.Available)), + Percent: pct, + }) + } + } + + if hwErr != nil { + log.Printf("restconf hardware: %v", hwErr) + } else { + for _, c := range hw.Hardware.Component { + class := shortClass(c.Class) + if class == "chassis" { + data.Board = boardInfo{ + Model: c.ModelName, + Manufacturer: c.MfgName, + SerialNum: c.SerialNum, + HardwareRev: c.HardwareRev, + BaseMAC: c.PhysAddress, + } + } + if c.SensorData != nil && c.SensorData.OperStatus == "ok" { + data.Sensors = append(data.Sensors, sensorEntry{ + Name: c.Name, + Value: formatSensor(c.SensorData.ValueType, int64(c.SensorData.Value), c.SensorData.ValueScale), + Type: c.SensorData.ValueType, + }) + } + } + } + + if confErr != nil { + log.Printf("restconf system config: %v", confErr) + } else { + data.Hostname = sysConf.System.Hostname + } + + tmplName := "dashboard.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// firmwareVersion returns the version string for the booted software slot. +func firmwareVersion(sw software) string { + for _, slot := range sw.Slot { + if slot.Name == sw.Booted { + return slot.Version + } + } + return "" +} + +// computeUptime returns a human-readable uptime string from RFC3339 timestamps. +func computeUptime(boot, now string) string { + bootT, err := time.Parse(time.RFC3339, boot) + if err != nil { + return "" + } + nowT, err := time.Parse(time.RFC3339, now) + if err != nil { + nowT = time.Now() + } + + d := nowT.Sub(bootT) + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + mins := int(d.Minutes()) % 60 + + switch { + case days > 0: + return fmt.Sprintf("%dd %dh %dm", days, hours, mins) + case hours > 0: + return fmt.Sprintf("%dh %dm", hours, mins) + default: + return fmt.Sprintf("%dm", mins) + } +} + +// shortClass strips the YANG module prefix from a hardware class identity. +func shortClass(full string) string { + if i := strings.LastIndex(full, ":"); i >= 0 { + return full[i+1:] + } + return full +} + +// formatSensor converts a raw sensor value to a human-readable string, +// matching the formatting used by cli_pretty. +func formatSensor(valueType string, value int64, scale string) string { + v := float64(value) + switch scale { + case "milli": + v /= 1000 + case "micro": + v /= 1000000 + } + switch valueType { + case "celsius": + return fmt.Sprintf("%.1f\u00b0C", v) + case "rpm": + return fmt.Sprintf("%.0f RPM", v) + case "volts-DC": + return fmt.Sprintf("%.2f VDC", v) + case "amperes": + return fmt.Sprintf("%.2f A", v) + case "watts": + return fmt.Sprintf("%.2f W", v) + default: + return fmt.Sprintf("%.1f", v) + } +} + +// humanBytes converts bytes to a human-readable string (B, KiB, MiB, GiB, TiB). +func humanBytes(b int64) string { + v := float64(b) + for _, unit := range []string{"B", "KiB", "MiB", "GiB", "TiB"} { + if v < 1024 || unit == "TiB" { + if v == math.Trunc(v) { + return fmt.Sprintf("%.0f %s", v, unit) + } + return fmt.Sprintf("%.1f %s", v, unit) + } + v /= 1024 + } + return fmt.Sprintf("%.1f PiB", v) +} + +// humanKiB converts KiB to a human-readable string (K, M, G, T). +func humanKiB(kib int64) string { + v := float64(kib) + for _, unit := range []string{"K", "M", "G", "T"} { + if v < 1024 || unit == "T" { + if v == math.Trunc(v) { + return fmt.Sprintf("%.0f%s", v, unit) + } + return fmt.Sprintf("%.1f%s", v, unit) + } + v /= 1024 + } + return fmt.Sprintf("%.1fP", v) +} diff --git a/src/webui/internal/handlers/dashboard_test.go b/src/webui/internal/handlers/dashboard_test.go new file mode 100644 index 00000000..d996ad84 --- /dev/null +++ b/src/webui/internal/handlers/dashboard_test.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "html/template" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kernelkit/webui/internal/restconf" + "github.com/kernelkit/webui/internal/security" +) + +var minimalDashTmpl = template.Must(template.New("dashboard.html").Parse( + `{{define "dashboard.html"}}hostname={{.Hostname}} error={{.Error}}{{end}}` + + `{{define "content"}}{{.Hostname}}{{end}}`, +)) + +func TestDashboardIndex_ReturnsOK(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &DashboardHandler{Template: minimalDashTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "testuser", + Password: "testpass", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Index(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d", w.Code) + } +} + +func TestDashboardIndex_ShowsErrorOnRESTCONFFailure(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &DashboardHandler{Template: minimalDashTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "tok") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Index(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body") + } +} + +func TestDashboardIndex_HTMXPartial(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &DashboardHandler{Template: minimalDashTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("HX-Request", "true") + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "tok") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Index(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } +} diff --git a/src/webui/internal/handlers/dhcp.go b/src/webui/internal/handlers/dhcp.go new file mode 100644 index 00000000..4c81731d --- /dev/null +++ b/src/webui/internal/handlers/dhcp.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "fmt" + "html/template" + "log" + "net/http" + "time" + + "github.com/kernelkit/webui/internal/restconf" +) + +// ─── DHCP types ────────────────────────────────────────────────────────────── + +// DHCPLease is a single active DHCP lease. +type DHCPLease struct { + Address string + MAC string + Hostname string + Expires string // relative or "never" + ClientID string +} + +// DHCPStats holds DHCP packet counters. +type DHCPStats struct { + InDiscoveries int64 + InRequests int64 + InReleases int64 + OutOffers int64 + OutAcks int64 + OutNaks int64 +} + +// DHCPData is the parsed DHCP server state. +type DHCPData struct { + Enabled bool + Leases []DHCPLease + Stats DHCPStats +} + +// ─── Page data ─────────────────────────────────────────────────────────────── + +type dhcpPageData struct { + CsrfToken string + PageTitle string + ActivePage string + Capabilities *Capabilities + DHCP *DHCPData + Error string +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// DHCPHandler serves the DHCP status page. +type DHCPHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the DHCP page (GET /dhcp). +func (h *DHCPHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := dhcpPageData{ + CsrfToken: csrfToken(r.Context()), + PageTitle: "DHCP Server", + ActivePage: "dhcp", + Capabilities: CapabilitiesFromContext(r.Context()), + } + + ctx := context.WithoutCancel(r.Context()) + + var raw struct { + DHCP struct { + Enabled yangBool `json:"enabled"` + Leases struct { + Lease []struct { + Address string `json:"address"` + PhysAddr string `json:"phys-address"` + Hostname string `json:"hostname"` + Expires string `json:"expires"` + ClientID string `json:"client-id"` + } `json:"lease"` + } `json:"leases"` + Statistics struct { + OutOffers yangInt64 `json:"out-offers"` + OutAcks yangInt64 `json:"out-acks"` + OutNaks yangInt64 `json:"out-naks"` + InDiscoveries yangInt64 `json:"in-discovers"` + InRequests yangInt64 `json:"in-requests"` + InReleases yangInt64 `json:"in-releases"` + } `json:"statistics"` + } `json:"infix-dhcp-server:dhcp-server"` + } + if err := h.RC.Get(ctx, "/data/infix-dhcp-server:dhcp-server", &raw); err != nil { + log.Printf("restconf dhcp-server: %v", err) + data.Error = "Failed to fetch DHCP data" + } else { + d := raw.DHCP + dhcp := &DHCPData{ + Enabled: bool(d.Enabled), + Stats: DHCPStats{ + InDiscoveries: int64(d.Statistics.InDiscoveries), + InRequests: int64(d.Statistics.InRequests), + InReleases: int64(d.Statistics.InReleases), + OutOffers: int64(d.Statistics.OutOffers), + OutAcks: int64(d.Statistics.OutAcks), + OutNaks: int64(d.Statistics.OutNaks), + }, + } + for _, l := range d.Leases.Lease { + dhcp.Leases = append(dhcp.Leases, DHCPLease{ + Address: l.Address, + MAC: l.PhysAddr, + Hostname: l.Hostname, + Expires: formatDHCPExpiry(l.Expires), + ClientID: l.ClientID, + }) + } + data.DHCP = dhcp + } + + tmplName := "dhcp.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// formatDHCPExpiry converts a YANG date-and-time or "never" string to a +// human-readable relative expiry string. +func formatDHCPExpiry(s string) string { + if s == "" || s == "never" { + return "never" + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + // Try without timezone + t, err = time.Parse("2006-01-02T15:04:05", s) + if err != nil { + return s + } + } + d := time.Until(t) + if d < 0 { + d = -d + return "expired " + formatRelDuration(d) + " ago" + } + return "in " + formatRelDuration(d) +} + +// formatRelDuration formats a time.Duration in a compact human-readable form. +func formatRelDuration(d time.Duration) string { + switch { + case d >= 24*time.Hour: + return fmt.Sprintf("%dd", int(d.Hours())/24) + case d >= time.Hour: + return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60) + case d >= time.Minute: + return fmt.Sprintf("%dm", int(d.Minutes())) + default: + return fmt.Sprintf("%ds", int(d.Seconds())) + } +} diff --git a/src/webui/internal/handlers/firewall.go b/src/webui/internal/handlers/firewall.go new file mode 100644 index 00000000..64c48582 --- /dev/null +++ b/src/webui/internal/handlers/firewall.go @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "html/template" + "log" + "net/http" + "sort" + "strings" + + "github.com/kernelkit/webui/internal/restconf" +) + +// RESTCONF JSON structures for infix-firewall:firewall. + +type firewallWrapper struct { + Firewall firewallJSON `json:"infix-firewall:firewall"` +} + +type firewallJSON struct { + Enabled *yangBool `json:"enabled"` // YANG default: true; nil means enabled + Default string `json:"default"` + Logging string `json:"logging"` + Lockdown yangBool `json:"lockdown"` + Zone []zoneJSON `json:"zone"` + Policy []policyJSON `json:"policy"` +} + +type zoneJSON struct { + Name string `json:"name"` + Action string `json:"action"` + Interface []string `json:"interface"` + Network []string `json:"network"` + Service []string `json:"service"` + PortForward []portForwardJSON `json:"port-forward"` + Immutable bool `json:"immutable"` +} + +type portForwardJSON struct { + Port string `json:"port"` + Protocol string `json:"protocol"` + ToAddr string `json:"to-addr"` + ToPort string `json:"to-port"` +} + +type policyJSON struct { + Name string `json:"name"` + Action string `json:"action"` + Priority yangInt64 `json:"priority"` + Ingress []string `json:"ingress"` + Egress []string `json:"egress"` + Service []string `json:"service"` + Masquerade bool `json:"masquerade"` + Immutable bool `json:"immutable"` +} + +// Template data structures. + +type firewallData struct { + CsrfToken string + Username string + ActivePage string + PageTitle string + Capabilities *Capabilities + Enabled bool + EnabledText string + DefaultZone string + Lockdown bool + Logging string + ZoneNames []string + Matrix []matrixRow + Zones []zoneEntry + Policies []policyEntry + Error string +} + +type matrixRow struct { + Zone string + Cells []matrixCell +} + +type matrixCell struct { + Class string + Symbol string +} + +type zoneEntry struct { + Name string + Action string + Interfaces string + Networks string + Services string +} + +type policyEntry struct { + Name string + Action string + Priority int64 + Ingress string + Egress string + Services string + Masquerade bool +} + +// FirewallHandler serves the firewall overview page. +type FirewallHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the firewall overview (GET /firewall). +func (h *FirewallHandler) Overview(w http.ResponseWriter, r *http.Request) { + creds := restconf.CredentialsFromContext(r.Context()) + data := firewallData{ + Username: creds.Username, + CsrfToken: csrfToken(r.Context()), + ActivePage: "firewall", + PageTitle: "Firewall", + Capabilities: CapabilitiesFromContext(r.Context()), + } + + var fw firewallWrapper + if err := h.RC.Get(r.Context(), "/data/infix-firewall:firewall", &fw); err != nil { + log.Printf("restconf firewall: %v", err) + data.Error = "Could not fetch firewall configuration" + } else { + f := fw.Firewall + data.Enabled = f.Enabled == nil || bool(*f.Enabled) + if data.Enabled { + data.EnabledText = "Active" + } else { + data.EnabledText = "Inactive" + } + data.DefaultZone = f.Default + data.Lockdown = bool(f.Lockdown) + data.Logging = f.Logging + if data.Logging == "" { + data.Logging = "off" + } + + for _, z := range f.Zone { + data.Zones = append(data.Zones, zoneEntry{ + Name: z.Name, + Action: z.Action, + Interfaces: strings.Join(z.Interface, ", "), + Networks: strings.Join(z.Network, ", "), + Services: strings.Join(z.Service, ", "), + }) + } + + for _, p := range f.Policy { + data.Policies = append(data.Policies, policyEntry{ + Name: p.Name, + Action: p.Action, + Priority: int64(p.Priority), + Ingress: strings.Join(p.Ingress, ", "), + Egress: strings.Join(p.Egress, ", "), + Services: strings.Join(p.Service, ", "), + Masquerade: p.Masquerade, + }) + } + + sort.Slice(data.Policies, func(i, j int) bool { + return data.Policies[i].Priority < data.Policies[j].Priority + }) + + data.ZoneNames, data.Matrix = buildMatrix(f.Zone, f.Policy) + } + + tmplName := "firewall.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// buildMatrix creates the zone-to-zone traffic flow matrix. +// Zones are listed along both axes with HOST (the device itself) prepended. +// Each cell shows whether traffic from the row zone to the column zone +// is allowed, denied, or conditional. +func buildMatrix(zones []zoneJSON, policies []policyJSON) ([]string, []matrixRow) { + if len(zones) == 0 { + return nil, nil + } + + names := []string{"HOST"} + zoneAction := map[string]string{} + for _, z := range zones { + names = append(names, z.Name) + zoneAction[z.Name] = z.Action + } + + // Sort policies by priority for evaluation. + sorted := make([]policyJSON, len(policies)) + copy(sorted, policies) + sort.Slice(sorted, func(i, j int) bool { + return int64(sorted[i].Priority) < int64(sorted[j].Priority) + }) + + rows := make([]matrixRow, len(names)) + for i, src := range names { + rows[i] = matrixRow{Zone: src, Cells: make([]matrixCell, len(names))} + for j, dst := range names { + switch { + case src == "HOST" && dst == "HOST": + rows[i].Cells[j] = matrixCell{Class: "matrix-self", Symbol: "\u2014"} + case src == "HOST": + // Traffic originating from the device is always allowed. + rows[i].Cells[j] = matrixCell{Class: "matrix-allow", Symbol: "\u2713"} + case src == dst: + rows[i].Cells[j] = matrixCell{Class: "matrix-self", Symbol: "\u2014"} + case dst == "HOST": + // Input to device: governed by zone action + policies. + rows[i].Cells[j] = zoneToHost(src, zoneAction, sorted) + default: + // Forwarding between zones: governed by policies. + rows[i].Cells[j] = evalForward(src, dst, sorted) + } + } + } + + return names, rows +} + +// zoneToHost determines traffic flow from a zone to the device (HOST). +// Policies are checked first; if none gives a verdict, the zone's +// default action is used. +func zoneToHost(zone string, zoneAction map[string]string, policies []policyJSON) matrixCell { + if v := evalPolicies(zone, "HOST", policies); v != "" { + return makeCell(v) + } + if zoneAction[zone] == "accept" { + return matrixCell{Class: "matrix-allow", Symbol: "\u2713"} + } + return matrixCell{Class: "matrix-deny", Symbol: "\u2717"} +} + +// evalForward determines traffic flow between two different zones. +func evalForward(src, dst string, policies []policyJSON) matrixCell { + if v := evalPolicies(src, dst, policies); v != "" { + return makeCell(v) + } + return matrixCell{Class: "matrix-deny", Symbol: "\u2717"} +} + +// evalPolicies walks the sorted policy list and returns the first terminal +// verdict (accept/reject/drop) for traffic from src to dst. +// "continue" policies are skipped (they don't produce a final verdict). +func evalPolicies(src, dst string, policies []policyJSON) string { + for _, p := range policies { + if !matchesZone(src, p.Ingress) || !matchesZone(dst, p.Egress) { + continue + } + if p.Action == "continue" { + continue + } + return p.Action + } + return "" +} + +// matchesZone checks whether zone appears in list, treating "ANY" as a wildcard. +func matchesZone(zone string, list []string) bool { + for _, z := range list { + if z == zone || z == "ANY" { + return true + } + } + return false +} + +func makeCell(verdict string) matrixCell { + if verdict == "accept" { + return matrixCell{Class: "matrix-allow", Symbol: "\u2713"} + } + return matrixCell{Class: "matrix-deny", Symbol: "\u2717"} +} diff --git a/src/webui/internal/handlers/interfaces.go b/src/webui/internal/handlers/interfaces.go new file mode 100644 index 00000000..58687bc5 --- /dev/null +++ b/src/webui/internal/handlers/interfaces.go @@ -0,0 +1,988 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "fmt" + "html/template" + "log" + "math" + "net/http" + "sort" + "strings" + + "github.com/kernelkit/webui/internal/restconf" +) + +// RESTCONF JSON structures for ietf-interfaces:interfaces. + +type interfacesWrapper struct { + Interfaces struct { + Interface []ifaceJSON `json:"interface"` + } `json:"ietf-interfaces:interfaces"` +} + +type ifaceJSON struct { + Name string `json:"name"` + Type string `json:"type"` + OperStatus string `json:"oper-status"` + PhysAddress string `json:"phys-address"` + IfIndex int `json:"if-index"` + IPv4 *ipCfg `json:"ietf-ip:ipv4"` + IPv6 *ipCfg `json:"ietf-ip:ipv6"` + Statistics *ifaceStats `json:"statistics"` + Ethernet *ethernetJSON `json:"ieee802-ethernet-interface:ethernet"` + BridgePort *bridgePortJSON `json:"infix-interfaces:bridge-port"` + WiFi *wifiJSON `json:"infix-interfaces:wifi"` + WireGuard *wireGuardJSON `json:"infix-interfaces:wireguard"` +} + +type bridgePortJSON struct { + Bridge string `json:"bridge"` + STP *struct { + CIST *struct { + State string `json:"state"` + } `json:"cist"` + } `json:"stp"` +} + +type wifiJSON struct { + Radio string `json:"radio"` + AccessPoint *wifiAPJSON `json:"access-point"` + Station *wifiStationJSON `json:"station"` +} + +type wifiAPJSON struct { + SSID string `json:"ssid"` + Stations struct { + Station []wifiStaJSON `json:"station"` + } `json:"stations"` +} + +type wifiStaJSON struct { + MACAddress string `json:"mac-address"` + SignalStrength *int `json:"signal-strength"` + ConnectedTime yangInt64 `json:"connected-time"` + RxPackets yangInt64 `json:"rx-packets"` + TxPackets yangInt64 `json:"tx-packets"` + RxBytes yangInt64 `json:"rx-bytes"` + TxBytes yangInt64 `json:"tx-bytes"` + RxSpeed yangInt64 `json:"rx-speed"` + TxSpeed yangInt64 `json:"tx-speed"` +} + +type wifiStationJSON struct { + SSID string `json:"ssid"` + SignalStrength *int `json:"signal-strength"` + RxSpeed yangInt64 `json:"rx-speed"` + TxSpeed yangInt64 `json:"tx-speed"` + ScanResults []wifiScanResultJSON `json:"scan-results"` +} + +type wifiScanResultJSON struct { + SSID string `json:"ssid"` + BSSID string `json:"bssid"` + SignalStrength *int `json:"signal-strength"` + Channel int `json:"channel"` + Encryption []string `json:"encryption"` +} + +// WiFi radio survey RESTCONF structures (from ietf-hardware:hardware). + +type wifiRadioJSON struct { + Survey *wifiSurveyJSON `json:"survey"` +} + +type wifiSurveyJSON struct { + Channel []surveyChanJSON `json:"channel"` +} + +type surveyChanJSON struct { + Frequency int `json:"frequency"` + InUse yangBool `json:"in-use"` + Noise int `json:"noise"` + ActiveTime int `json:"active-time"` + BusyTime int `json:"busy-time"` + ReceiveTime int `json:"receive-time"` + TransmitTime int `json:"transmit-time"` +} + +type wireGuardJSON struct { + PeerStatus *struct { + Peer []wgPeerJSON `json:"peer"` + } `json:"peer-status"` +} + +type wgPeerJSON struct { + PublicKey string `json:"public-key"` + ConnectionStatus string `json:"connection-status"` + EndpointAddress string `json:"endpoint-address"` + EndpointPort int `json:"endpoint-port"` + LatestHandshake string `json:"latest-handshake"` + Transfer *struct { + TxBytes yangInt64 `json:"tx-bytes"` + RxBytes yangInt64 `json:"rx-bytes"` + } `json:"transfer"` +} + +type ipCfg struct { + Address []ipAddr `json:"address"` + MTU int `json:"mtu"` +} + +type ipAddr struct { + IP string `json:"ip"` + PrefixLength yangInt64 `json:"prefix-length"` + Origin string `json:"origin"` +} + +type ifaceStats struct { + InOctets yangInt64 `json:"in-octets"` + OutOctets yangInt64 `json:"out-octets"` + InUnicastPkts yangInt64 `json:"in-unicast-pkts"` + InBroadcastPkts yangInt64 `json:"in-broadcast-pkts"` + InMulticastPkts yangInt64 `json:"in-multicast-pkts"` + InDiscards yangInt64 `json:"in-discards"` + InErrors yangInt64 `json:"in-errors"` + OutUnicastPkts yangInt64 `json:"out-unicast-pkts"` + OutBroadcastPkts yangInt64 `json:"out-broadcast-pkts"` + OutMulticastPkts yangInt64 `json:"out-multicast-pkts"` + OutDiscards yangInt64 `json:"out-discards"` + OutErrors yangInt64 `json:"out-errors"` +} + +type ethernetJSON struct { + Speed string `json:"speed"` + Duplex string `json:"duplex"` + AutoNegotiation *struct { + Enable bool `json:"enable"` + } `json:"auto-negotiation"` + Statistics *struct { + Frame *ethFrameStats `json:"frame"` + } `json:"statistics"` +} + +type ethFrameStats struct { + InTotalPkts yangInt64 `json:"in-total-pkts"` + InTotalOctets yangInt64 `json:"in-total-octets"` + InGoodPkts yangInt64 `json:"in-good-pkts"` + InGoodOctets yangInt64 `json:"in-good-octets"` + InBroadcast yangInt64 `json:"in-broadcast"` + InMulticast yangInt64 `json:"in-multicast"` + InErrorFCS yangInt64 `json:"in-error-fcs"` + InErrorUndersize yangInt64 `json:"in-error-undersize"` + InErrorOversize yangInt64 `json:"in-error-oversize"` + InErrorMACInternal yangInt64 `json:"in-error-mac-internal"` + OutTotalPkts yangInt64 `json:"out-total-pkts"` + OutTotalOctets yangInt64 `json:"out-total-octets"` + OutGoodPkts yangInt64 `json:"out-good-pkts"` + OutGoodOctets yangInt64 `json:"out-good-octets"` + OutBroadcast yangInt64 `json:"out-broadcast"` + OutMulticast yangInt64 `json:"out-multicast"` +} + +// Template data structures. + +type interfacesData struct { + CsrfToken string + Username string + ActivePage string + Capabilities *Capabilities + PageTitle string + Interfaces []ifaceEntry + Error string +} + +type ifaceEntry struct { + Indent string // tree prefix for bridge/LAG members + Name string + Type string + Status string + StatusUp bool + PhysAddr string + Addresses []addrEntry + Detail string // extra info: wifi AP, wireguard peers, etc. + RxBytes string + TxBytes string +} + +type addrEntry struct { + Address string + Origin string +} + +// InterfacesHandler serves the interfaces pages. +type InterfacesHandler struct { + Template *template.Template + DetailTemplate *template.Template + CountersTemplate *template.Template + RC *restconf.Client +} + +// Overview renders the interfaces page (GET /interfaces). +func (h *InterfacesHandler) Overview(w http.ResponseWriter, r *http.Request) { + creds := restconf.CredentialsFromContext(r.Context()) + data := interfacesData{ + Username: creds.Username, + CsrfToken: csrfToken(r.Context()), + ActivePage: "interfaces", + PageTitle: "Interfaces", + Capabilities: CapabilitiesFromContext(r.Context()), + } + + var ifaces interfacesWrapper + if err := h.RC.Get(r.Context(), "/data/ietf-interfaces:interfaces", &ifaces); err != nil { + log.Printf("restconf interfaces: %v", err) + data.Error = "Could not fetch interface information" + } else { + data.Interfaces = buildIfaceList(ifaces.Interfaces.Interface) + } + + tmplName := "interfaces.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// prettyIfType converts a YANG interface type identity to the display +// name used by the Infix CLI (cli_pretty). +func prettyIfType(full string) string { + pretty := map[string]string{ + "bridge": "bridge", + "dummy": "dummy", + "ethernet": "ethernet", + "gre": "gre", + "gretap": "gretap", + "vxlan": "vxlan", + "wireguard": "wireguard", + "lag": "lag", + "loopback": "loopback", + "veth": "veth", + "vlan": "vlan", + "wifi": "wifi", + "other": "other", + "ethernetCsmacd": "ethernet", + "softwareLoopback": "loopback", + "l2vlan": "vlan", + "ieee8023adLag": "lag", + "ieee80211": "wifi", + "ilan": "veth", + } + + if i := strings.LastIndex(full, ":"); i >= 0 { + full = full[i+1:] + } + if name, ok := pretty[full]; ok { + return name + } + return full +} + +// buildIfaceList converts raw RESTCONF interface data into a flat, +// hierarchically ordered display list matching the cli_pretty style. +// Bridge members are grouped under their parent with tree indicators. +func buildIfaceList(raw []ifaceJSON) []ifaceEntry { + byName := map[string]*ifaceJSON{} + children := map[string][]string{} + childSet := map[string]bool{} + + for i := range raw { + iface := &raw[i] + byName[iface.Name] = iface + if iface.BridgePort != nil && iface.BridgePort.Bridge != "" { + parent := iface.BridgePort.Bridge + children[parent] = append(children[parent], iface.Name) + childSet[iface.Name] = true + } + } + + var result []ifaceEntry + + for _, iface := range raw { + if childSet[iface.Name] { + continue + } + + result = append(result, makeIfaceEntry(iface, "")) + + members := children[iface.Name] + for i, childName := range members { + child, ok := byName[childName] + if !ok { + continue + } + prefix := "\u251c\u00a0" // ├ + if i == len(members)-1 { + prefix = "\u2514\u00a0" // └ + } + e := makeIfaceEntry(*child, prefix) + if child.BridgePort != nil && child.BridgePort.STP != nil && + child.BridgePort.STP.CIST != nil && child.BridgePort.STP.CIST.State != "" { + e.Status = child.BridgePort.STP.CIST.State + e.StatusUp = e.Status == "forwarding" + } + result = append(result, e) + } + } + + return result +} + +func makeIfaceEntry(iface ifaceJSON, indent string) ifaceEntry { + e := ifaceEntry{ + Indent: indent, + Name: iface.Name, + Type: prettyIfType(iface.Type), + Status: iface.OperStatus, + StatusUp: iface.OperStatus == "up", + PhysAddr: iface.PhysAddress, + } + + if iface.Statistics != nil { + e.RxBytes = humanBytes(int64(iface.Statistics.InOctets)) + e.TxBytes = humanBytes(int64(iface.Statistics.OutOctets)) + } + + if iface.IPv4 != nil { + for _, a := range iface.IPv4.Address { + e.Addresses = append(e.Addresses, addrEntry{ + Address: fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength)), + Origin: a.Origin, + }) + } + } + if iface.IPv6 != nil { + for _, a := range iface.IPv6.Address { + e.Addresses = append(e.Addresses, addrEntry{ + Address: fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength)), + Origin: a.Origin, + }) + } + } + + if iface.WiFi != nil { + if ap := iface.WiFi.AccessPoint; ap != nil { + n := len(ap.Stations.Station) + e.Detail = fmt.Sprintf("AP, ssid: %s, stations: %d", ap.SSID, n) + } else if st := iface.WiFi.Station; st != nil { + e.Detail = fmt.Sprintf("Station, ssid: %s", st.SSID) + } + } + + if wg := iface.WireGuard; wg != nil && wg.PeerStatus != nil { + total := len(wg.PeerStatus.Peer) + up := 0 + for _, p := range wg.PeerStatus.Peer { + if p.ConnectionStatus == "up" { + up++ + } + } + e.Detail = fmt.Sprintf("%d peers (%d up)", total, up) + } + + return e +} + +// Template data for the interface detail page. +type ifaceDetailData struct { + CsrfToken string + Username string + ActivePage string + Capabilities *Capabilities + PageTitle string + Name string + Type string + Status string + StatusUp bool + PhysAddr string + IfIndex int + MTU int + Speed string + Duplex string + AutoNeg string + Addresses []addrEntry + WiFiMode string // "Access Point" or "Station" + WiFiSSID string + WiFiSignal string + WiFiRxSpeed string + WiFiTxSpeed string + WiFiStationCount string // e.g. "3" for AP mode + WGPeerSummary string // e.g. "3 peers (2 up)" + Counters ifaceCounters + EthFrameStats []kvEntry + WGPeers []wgPeerEntry + WiFiStations []wifiStaEntry + ScanResults []wifiScanEntry +} + +type ifaceCounters struct { + RxBytes string + RxUnicast string + RxBroadcast string + RxMulticast string + RxDiscards string + RxErrors string + TxBytes string + TxUnicast string + TxBroadcast string + TxMulticast string + TxDiscards string + TxErrors string +} + +type kvEntry struct { + Key string + Value string +} + +type wgPeerEntry struct { + PublicKey string + Status string + StatusUp bool + Endpoint string + Handshake string + TxBytes string + RxBytes string +} + +type wifiStaEntry struct { + MAC string + Signal string + SignalCSS string // "excellent", "good", "poor", "bad" + Time string + RxPkts string + TxPkts string + RxBytes string + TxBytes string + RxSpeed string + TxSpeed string +} + +type wifiScanEntry struct { + SSID string + BSSID string + Signal string + SignalCSS string + Channel string + Encryption string +} + +// fetchInterface retrieves a single interface by name from RESTCONF. +func (h *InterfacesHandler) fetchInterface(r *http.Request, name string) (*ifaceJSON, error) { + var all interfacesWrapper + if err := h.RC.Get(r.Context(), "/data/ietf-interfaces:interfaces", &all); err != nil { + return nil, err + } + for i := range all.Interfaces.Interface { + if all.Interfaces.Interface[i].Name == name { + return &all.Interfaces.Interface[i], nil + } + } + return nil, fmt.Errorf("interface %q not found", name) +} + +// buildDetailData converts raw RESTCONF interface data to template data. +func buildDetailData(username, csrf string, iface *ifaceJSON) ifaceDetailData { + d := ifaceDetailData{ + Username: username, + CsrfToken: csrf, + Name: iface.Name, + Type: prettyIfType(iface.Type), + Status: iface.OperStatus, + StatusUp: iface.OperStatus == "up", + PhysAddr: iface.PhysAddress, + IfIndex: iface.IfIndex, + } + + if iface.IPv4 != nil { + if iface.IPv4.MTU > 0 { + d.MTU = iface.IPv4.MTU + } + for _, a := range iface.IPv4.Address { + d.Addresses = append(d.Addresses, addrEntry{ + Address: fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength)), + Origin: a.Origin, + }) + } + } + if iface.IPv6 != nil { + for _, a := range iface.IPv6.Address { + d.Addresses = append(d.Addresses, addrEntry{ + Address: fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength)), + Origin: a.Origin, + }) + } + } + + if iface.Ethernet != nil { + d.Speed = prettySpeed(iface.Ethernet.Speed) + d.Duplex = iface.Ethernet.Duplex + if iface.Ethernet.AutoNegotiation != nil { + if iface.Ethernet.AutoNegotiation.Enable { + d.AutoNeg = "on" + } else { + d.AutoNeg = "off" + } + } + if iface.Ethernet.Statistics != nil && iface.Ethernet.Statistics.Frame != nil { + d.EthFrameStats = buildEthFrameStats(iface.Ethernet.Statistics.Frame) + } + } + + if iface.Statistics != nil { + d.Counters = buildCounters(iface.Statistics) + } + + if iface.WiFi != nil { + if ap := iface.WiFi.AccessPoint; ap != nil { + d.WiFiMode = "Access Point" + d.WiFiSSID = ap.SSID + d.WiFiStationCount = fmt.Sprintf("%d", len(ap.Stations.Station)) + for _, s := range ap.Stations.Station { + d.WiFiStations = append(d.WiFiStations, buildWifiStaEntry(s)) + } + } else if st := iface.WiFi.Station; st != nil { + d.WiFiMode = "Station" + d.WiFiSSID = st.SSID + if st.SignalStrength != nil { + d.WiFiSignal = fmt.Sprintf("%d dBm", *st.SignalStrength) + } + if st.RxSpeed > 0 { + d.WiFiRxSpeed = fmt.Sprintf("%.1f Mbps", float64(st.RxSpeed)/10) + } + if st.TxSpeed > 0 { + d.WiFiTxSpeed = fmt.Sprintf("%.1f Mbps", float64(st.TxSpeed)/10) + } + for _, sr := range st.ScanResults { + d.ScanResults = append(d.ScanResults, buildWifiScanEntry(sr)) + } + } + } + + if wg := iface.WireGuard; wg != nil && wg.PeerStatus != nil { + total := len(wg.PeerStatus.Peer) + up := 0 + for _, p := range wg.PeerStatus.Peer { + pe := wgPeerEntry{ + PublicKey: p.PublicKey, + Status: p.ConnectionStatus, + StatusUp: p.ConnectionStatus == "up", + } + if p.EndpointAddress != "" { + pe.Endpoint = fmt.Sprintf("%s:%d", p.EndpointAddress, p.EndpointPort) + } + if p.LatestHandshake != "" { + pe.Handshake = p.LatestHandshake + } + if p.Transfer != nil { + pe.TxBytes = humanBytes(int64(p.Transfer.TxBytes)) + pe.RxBytes = humanBytes(int64(p.Transfer.RxBytes)) + } + if p.ConnectionStatus == "up" { + up++ + } + d.WGPeers = append(d.WGPeers, pe) + } + d.WGPeerSummary = fmt.Sprintf("%d peers (%d up)", total, up) + } + + return d +} + +func buildCounters(s *ifaceStats) ifaceCounters { + return ifaceCounters{ + RxBytes: humanBytes(int64(s.InOctets)), + RxUnicast: formatCount(int64(s.InUnicastPkts)), + RxBroadcast: formatCount(int64(s.InBroadcastPkts)), + RxMulticast: formatCount(int64(s.InMulticastPkts)), + RxDiscards: formatCount(int64(s.InDiscards)), + RxErrors: formatCount(int64(s.InErrors)), + TxBytes: humanBytes(int64(s.OutOctets)), + TxUnicast: formatCount(int64(s.OutUnicastPkts)), + TxBroadcast: formatCount(int64(s.OutBroadcastPkts)), + TxMulticast: formatCount(int64(s.OutMulticastPkts)), + TxDiscards: formatCount(int64(s.OutDiscards)), + TxErrors: formatCount(int64(s.OutErrors)), + } +} + +func buildEthFrameStats(f *ethFrameStats) []kvEntry { + return []kvEntry{ + {"eth-in-frames", formatCount(int64(f.InTotalPkts))}, + {"eth-in-octets", humanBytes(int64(f.InTotalOctets))}, + {"eth-in-good-frames", formatCount(int64(f.InGoodPkts))}, + {"eth-in-good-octets", humanBytes(int64(f.InGoodOctets))}, + {"eth-in-broadcast", formatCount(int64(f.InBroadcast))}, + {"eth-in-multicast", formatCount(int64(f.InMulticast))}, + {"eth-in-fcs-error", formatCount(int64(f.InErrorFCS))}, + {"eth-in-undersize", formatCount(int64(f.InErrorUndersize))}, + {"eth-in-oversize", formatCount(int64(f.InErrorOversize))}, + {"eth-in-mac-error", formatCount(int64(f.InErrorMACInternal))}, + {"eth-out-frames", formatCount(int64(f.OutTotalPkts))}, + {"eth-out-octets", humanBytes(int64(f.OutTotalOctets))}, + {"eth-out-good-frames", formatCount(int64(f.OutGoodPkts))}, + {"eth-out-good-octets", humanBytes(int64(f.OutGoodOctets))}, + {"eth-out-broadcast", formatCount(int64(f.OutBroadcast))}, + {"eth-out-multicast", formatCount(int64(f.OutMulticast))}, + } +} + +// prettySpeed converts YANG ethernet speed identities to display strings. +func prettySpeed(s string) string { + if i := strings.LastIndex(s, ":"); i >= 0 { + s = s[i+1:] + } + return s +} + +func buildWifiStaEntry(s wifiStaJSON) wifiStaEntry { + e := wifiStaEntry{ + MAC: s.MACAddress, + Time: formatDuration(int64(s.ConnectedTime)), + RxPkts: formatCount(int64(s.RxPackets)), + TxPkts: formatCount(int64(s.TxPackets)), + RxBytes: humanBytes(int64(s.RxBytes)), + TxBytes: humanBytes(int64(s.TxBytes)), + RxSpeed: fmt.Sprintf("%.1f Mbps", float64(s.RxSpeed)/10), + TxSpeed: fmt.Sprintf("%.1f Mbps", float64(s.TxSpeed)/10), + } + if s.SignalStrength != nil { + sig := *s.SignalStrength + e.Signal = fmt.Sprintf("%d dBm", sig) + switch { + case sig >= -50: + e.SignalCSS = "excellent" + case sig >= -60: + e.SignalCSS = "good" + case sig >= -70: + e.SignalCSS = "poor" + default: + e.SignalCSS = "bad" + } + } + return e +} + +func buildWifiScanEntry(sr wifiScanResultJSON) wifiScanEntry { + e := wifiScanEntry{ + SSID: sr.SSID, + BSSID: sr.BSSID, + Channel: fmt.Sprintf("%d", sr.Channel), + } + if len(sr.Encryption) > 0 { + e.Encryption = strings.Join(sr.Encryption, ", ") + } else { + e.Encryption = "Open" + } + if sr.SignalStrength != nil { + sig := *sr.SignalStrength + e.Signal = fmt.Sprintf("%d dBm", sig) + switch { + case sig >= -50: + e.SignalCSS = "excellent" + case sig >= -60: + e.SignalCSS = "good" + case sig >= -70: + e.SignalCSS = "poor" + default: + e.SignalCSS = "bad" + } + } + return e +} + +func formatDuration(secs int64) string { + if secs < 60 { + return fmt.Sprintf("%ds", secs) + } + if secs < 3600 { + return fmt.Sprintf("%dm %ds", secs/60, secs%60) + } + h := secs / 3600 + m := (secs % 3600) / 60 + return fmt.Sprintf("%dh %dm", h, m) +} + +// formatCount formats a packet/frame count with thousand separators. +func formatCount(n int64) string { + if n == 0 { + return "0" + } + s := fmt.Sprintf("%d", n) + // Insert thousand separators from the right. + var result []byte + for i, c := range s { + if i > 0 && (len(s)-i)%3 == 0 { + result = append(result, ',') + } + result = append(result, byte(c)) + } + return string(result) +} + +// Detail renders the interface detail page (GET /interfaces/{name}). +func (h *InterfacesHandler) Detail(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + creds := restconf.CredentialsFromContext(r.Context()) + + iface, err := h.fetchInterface(r, name) + if err != nil { + log.Printf("restconf interface %s: %v", name, err) + http.Error(w, "Interface not found", http.StatusNotFound) + return + } + + data := buildDetailData(creds.Username, csrfToken(r.Context()), iface) + data.ActivePage = "interfaces" + data.PageTitle = "Interface " + name + data.Capabilities = CapabilitiesFromContext(r.Context()) + + tmplName := "iface-detail.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.DetailTemplate.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// Counters renders the counters fragment for htmx polling (GET /interfaces/{name}/counters). +func (h *InterfacesHandler) Counters(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + creds := restconf.CredentialsFromContext(r.Context()) + + iface, err := h.fetchInterface(r, name) + if err != nil { + log.Printf("restconf interface %s counters: %v", name, err) + http.Error(w, "Interface not found", http.StatusNotFound) + return + } + + data := buildDetailData(creds.Username, csrfToken(r.Context()), iface) + + if err := h.CountersTemplate.ExecuteTemplate(w, "iface-counters", data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// freqToChannel converts a WiFi center frequency (MHz) to a channel number. +func freqToChannel(freq int) int { + switch { + case freq == 2484: + return 14 + case freq >= 2412 && freq <= 2472: + return (freq - 2407) / 5 + case freq >= 5180 && freq <= 5885: + return (freq - 5000) / 5 + case freq >= 5955 && freq <= 7115: + return (freq - 5950) / 5 + default: + return 0 + } +} + +// renderSurveySVG generates an inline SVG bar chart visualizing WiFi channel +// survey data. Each channel gets a stacked bar showing receive, transmit, +// and other busy time as a percentage of active time. A dashed noise-floor +// line is overlaid with a right-side dBm axis. The in-use channel is marked +// with a triangle. +func renderSurveySVG(channels []surveyChanJSON) template.HTML { + n := len(channels) + if n == 0 { + return "" + } + + sorted := make([]surveyChanJSON, n) + copy(sorted, channels) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Frequency < sorted[j].Frequency + }) + + // Layout constants. + const chartH = 200 + padL, padR, padT, padB := 44, 48, 28, 58 + + slotW := 600.0 / float64(n) + if slotW > 44 { + slotW = 44 + } + if slotW < 16 { + slotW = 16 + } + barW := slotW * 0.65 + chartW := slotW * float64(n) + svgW := int(chartW) + padL + padR + svgH := chartH + padT + padB + + // Noise range for right axis. + hasNoise := false + noiseMin, noiseMax := 0, 0 + for i, ch := range sorted { + if ch.Noise != 0 { + if !hasNoise { + noiseMin, noiseMax = ch.Noise, ch.Noise + hasNoise = true + } + if ch.Noise < noiseMin { + noiseMin = ch.Noise + } + if ch.Noise > noiseMax { + noiseMax = ch.Noise + } + } + _ = i + } + nFloor := int(math.Floor(float64(noiseMin)/5))*5 - 5 + nCeil := int(math.Ceil(float64(noiseMax)/5))*5 + 5 + nRange := float64(nCeil - nFloor) + if nRange == 0 { + nRange = 10 + } + + var b strings.Builder + + fmt.Fprintf(&b, ``, + svgW, svgH) + + // Y-axis grid lines and labels (utilization %). + for _, pct := range []int{0, 25, 50, 75, 100} { + y := padT + chartH - pct*chartH/100 + fmt.Fprintf(&b, ``, + padL, y, float64(padL)+chartW, y) + fmt.Fprintf(&b, `%d%%`, + padL-4, y+4, pct) + } + + // Right Y-axis labels (noise dBm). + if hasNoise { + nMid := (nFloor + nCeil) / 2 + for _, db := range []int{nFloor + 5, nMid, nCeil - 5} { + ny := float64(padT+chartH) - float64(db-nFloor)/nRange*float64(chartH) + fmt.Fprintf(&b, `%d`, + float64(padL)+chartW+4, ny+3, db) + } + } + + // Draw bars and collect noise line points. + var noisePts []string + + for i, ch := range sorted { + cx := float64(padL) + float64(i)*slotW + slotW/2 + bx := cx - barW/2 + + var rxPct, txPct, otherPct float64 + if ch.ActiveTime > 0 { + act := float64(ch.ActiveTime) + rxPct = float64(ch.ReceiveTime) / act * 100 + txPct = float64(ch.TransmitTime) / act * 100 + otherPct = float64(ch.BusyTime)/act*100 - rxPct - txPct + if otherPct < 0 { + otherPct = 0 + } + if total := rxPct + txPct + otherPct; total > 100 { + s := 100 / total + rxPct *= s + txPct *= s + otherPct *= s + } + } + + baseY := float64(padT + chartH) + + // Stacked: other busy (bottom), transmit (middle), receive (top). + if otherPct > 0.5 { + h := otherPct / 100 * float64(chartH) + fmt.Fprintf(&b, ``, + bx, baseY-h, barW, h) + baseY -= h + } + if txPct > 0.5 { + h := txPct / 100 * float64(chartH) + fmt.Fprintf(&b, ``, + bx, baseY-h, barW, h) + baseY -= h + } + if rxPct > 0.5 { + h := rxPct / 100 * float64(chartH) + fmt.Fprintf(&b, ``, + bx, baseY-h, barW, h) + } + + // In-use marker (triangle above bar). + if ch.InUse { + totalPct := rxPct + txPct + otherPct + topY := float64(padT+chartH) - totalPct/100*float64(chartH) + fmt.Fprintf(&b, ``, + cx, topY-3, cx-4, topY-11, cx+4, topY-11) + } + + // Channel label on X-axis. + chNum := freqToChannel(ch.Frequency) + label := fmt.Sprintf("%d", chNum) + if chNum == 0 { + label = fmt.Sprintf("%d", ch.Frequency) + } + fmt.Fprintf(&b, `%s`, + cx, padT+chartH+14, label) + + // Noise line point. + if hasNoise && ch.Noise != 0 { + ny := float64(padT+chartH) - float64(ch.Noise-nFloor)/nRange*float64(chartH) + noisePts = append(noisePts, fmt.Sprintf("%.1f,%.1f", cx, ny)) + } + } + + // Draw noise floor line. + if len(noisePts) > 1 { + fmt.Fprintf(&b, ``, + strings.Join(noisePts, " ")) + for _, pt := range noisePts { + parts := strings.SplitN(pt, ",", 2) + fmt.Fprintf(&b, ``, + parts[0], parts[1]) + } + } + + // Legend row. + ly := svgH - 8 + lx := float64(padL) + + for _, item := range []struct{ color, label string }{ + {"#3b82f6", "Rx"}, + {"#22c55e", "Tx"}, + {"#f59e0b", "Other"}, + } { + fmt.Fprintf(&b, ``, + lx, ly-9, item.color) + fmt.Fprintf(&b, `%s`, + lx+13, ly, item.label) + lx += 13 + float64(len(item.label))*7 + 10 + } + + if hasNoise { + fmt.Fprintf(&b, ``, + lx, ly-4, lx+14, ly-4) + lx += 18 + fmt.Fprintf(&b, `Noise (dBm)`, + lx, ly) + lx += 80 + } + + // In-use legend marker. + fmt.Fprintf(&b, ``, + lx+5, ly-9, lx+1, ly-1, lx+9, ly-1) + fmt.Fprintf(&b, `In use`, + lx+14, ly) + + b.WriteString(``) + return template.HTML(b.String()) +} diff --git a/src/webui/internal/handlers/keystore.go b/src/webui/internal/handlers/keystore.go new file mode 100644 index 00000000..f76f0631 --- /dev/null +++ b/src/webui/internal/handlers/keystore.go @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "encoding/base64" + "html/template" + "log" + "net/http" + "strings" + + "github.com/kernelkit/webui/internal/restconf" +) + +// RESTCONF JSON structures for ietf-keystore:keystore. + +type keystoreWrapper struct { + Keystore keystoreJSON `json:"ietf-keystore:keystore"` +} + +type keystoreJSON struct { + SymmetricKeys symmetricKeysJSON `json:"symmetric-keys"` + AsymmetricKeys asymmetricKeysJSON `json:"asymmetric-keys"` +} + +type symmetricKeysJSON struct { + SymmetricKey []symmetricKeyJSON `json:"symmetric-key"` +} + +type symmetricKeyJSON struct { + Name string `json:"name"` + KeyFormat string `json:"key-format"` + CleartextSymmetricKey string `json:"cleartext-symmetric-key"` +} + +type asymmetricKeysJSON struct { + AsymmetricKey []asymmetricKeyJSON `json:"asymmetric-key"` +} + +type asymmetricKeyJSON struct { + Name string `json:"name"` + PrivateKeyFormat string `json:"private-key-format"` + PublicKeyFormat string `json:"public-key-format"` + PublicKey string `json:"public-key"` + CleartextPrivateKey string `json:"cleartext-private-key"` + Certificates certificatesJSON `json:"certificates"` +} + +type certificatesJSON struct { + Certificate []certificateJSON `json:"certificate"` +} + +type certificateJSON struct { + Name string `json:"name"` + CertData string `json:"cert-data"` +} + +// Template data structures. + +type keystoreData struct { + CsrfToken string + Username string + ActivePage string + PageTitle string + Capabilities *Capabilities + SymmetricKeys []symKeyEntry + AsymmetricKeys []asymKeyEntry + Empty bool + Error string +} + +type symKeyEntry struct { + Name string + Format string + Value string +} + +type asymKeyEntry struct { + Name string + Algorithm string + PublicKey string + PublicKeyFull string + PrivateKey string + PrivateKeyFull string + Certificates []string +} + +// KeystoreHandler serves the keystore overview page. +type KeystoreHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the keystore overview (GET /keystore). +func (h *KeystoreHandler) Overview(w http.ResponseWriter, r *http.Request) { + creds := restconf.CredentialsFromContext(r.Context()) + data := keystoreData{ + Username: creds.Username, + CsrfToken: csrfToken(r.Context()), + ActivePage: "keystore", + PageTitle: "Keystore", + Capabilities: CapabilitiesFromContext(r.Context()), + } + + var ks keystoreWrapper + if err := h.RC.Get(r.Context(), "/data/ietf-keystore:keystore", &ks); err != nil { + log.Printf("restconf keystore: %v", err) + data.Error = "Could not fetch keystore" + } else { + for _, k := range ks.Keystore.SymmetricKeys.SymmetricKey { + data.SymmetricKeys = append(data.SymmetricKeys, symKeyEntry{ + Name: k.Name, + Format: shortFormat(k.KeyFormat), + Value: decodeSymmetricValue(k), + }) + } + + for _, k := range ks.Keystore.AsymmetricKeys.AsymmetricKey { + entry := asymKeyEntry{ + Name: k.Name, + Algorithm: asymAlgorithm(k), + PublicKeyFull: k.PublicKey, + PublicKey: truncate(k.PublicKey, 40), + PrivateKeyFull: k.CleartextPrivateKey, + PrivateKey: truncate(k.CleartextPrivateKey, 40), + } + for _, c := range k.Certificates.Certificate { + entry.Certificates = append(entry.Certificates, c.Name) + } + data.AsymmetricKeys = append(data.AsymmetricKeys, entry) + } + + data.Empty = len(data.SymmetricKeys) == 0 && len(data.AsymmetricKeys) == 0 + } + + tmplName := "keystore.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// shortFormat strips the YANG module prefix and "-key-format" suffix. +// e.g. "ietf-crypto-types:octet-string-key-format" → "octet-string" +func shortFormat(full string) string { + if i := strings.LastIndex(full, ":"); i >= 0 { + full = full[i+1:] + } + full = strings.TrimSuffix(full, "-key-format") + full = strings.TrimSuffix(full, "-private-key-format") + full = strings.TrimSuffix(full, "-public-key-format") + return full +} + +// asymAlgorithm derives the key algorithm from the format fields. +func asymAlgorithm(k asymmetricKeyJSON) string { + for _, fmt := range []string{k.PrivateKeyFormat, k.PublicKeyFormat} { + name := shortFormat(fmt) + if name != "" { + return name + } + } + return "" +} + +// decodeSymmetricValue returns the displayable value for a symmetric key. +// Passphrases are base64-decoded to plaintext; others shown as-is. +func decodeSymmetricValue(k symmetricKeyJSON) string { + val := k.CleartextSymmetricKey + if val == "" { + return "-" + } + if shortFormat(k.KeyFormat) == "passphrase" { + if decoded, err := base64.StdEncoding.DecodeString(val); err == nil { + return string(decoded) + } + } + return val +} + +// truncate shortens s to max characters, adding "..." if truncated. +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-3] + "..." +} diff --git a/src/webui/internal/handlers/lldp.go b/src/webui/internal/handlers/lldp.go new file mode 100644 index 00000000..c3d9e19b --- /dev/null +++ b/src/webui/internal/handlers/lldp.go @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "encoding/json" + "html/template" + "log" + "net/http" + "strings" + + "github.com/kernelkit/webui/internal/restconf" +) + +// ─── LLDP types ────────────────────────────────────────────────────────────── + +// LLDPNeighbor is a remote system seen via LLDP. +type LLDPNeighbor struct { + LocalPort string + ChassisID string + SystemName string + PortID string + PortDesc string + SystemDesc string + Capabilities string // comma-separated + MgmtAddress string +} + +// ─── Page data ─────────────────────────────────────────────────────────────── + +type lldpPageData struct { + CsrfToken string + PageTitle string + ActivePage string + Capabilities *Capabilities + Neighbors []LLDPNeighbor + Error string +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// LLDPHandler serves the LLDP neighbors page. +type LLDPHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the LLDP page (GET /lldp). +func (h *LLDPHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := lldpPageData{ + CsrfToken: csrfToken(r.Context()), + PageTitle: "LLDP Neighbors", + ActivePage: "lldp", + Capabilities: CapabilitiesFromContext(r.Context()), + } + + ctx := context.WithoutCancel(r.Context()) + + var raw struct { + LLDP struct { + Port []struct { + Name string `json:"name"` + DestMACAddress string `json:"dest-mac-address"` + RemoteSystemsData []struct { + ChassisID string `json:"chassis-id"` + PortID string `json:"port-id"` + PortDesc string `json:"port-desc"` + SystemName string `json:"system-name"` + SystemDescription string `json:"system-description"` + SystemCapabilitiesEnabled json.RawMessage `json:"system-capabilities-enabled"` + ManagementAddress []struct { + Address string `json:"address"` + } `json:"management-address"` + } `json:"remote-systems-data"` + } `json:"port"` + } `json:"ieee802-dot1ab-lldp:lldp"` + } + if err := h.RC.Get(ctx, "/data/ieee802-dot1ab-lldp:lldp", &raw); err != nil { + log.Printf("restconf lldp: %v", err) + data.Error = "Failed to fetch LLDP data" + } else { + for _, port := range raw.LLDP.Port { + for _, rs := range port.RemoteSystemsData { + mgmt := "" + if len(rs.ManagementAddress) > 0 { + mgmt = rs.ManagementAddress[0].Address + } + data.Neighbors = append(data.Neighbors, LLDPNeighbor{ + LocalPort: port.Name, + ChassisID: rs.ChassisID, + SystemName: rs.SystemName, + PortID: rs.PortID, + PortDesc: rs.PortDesc, + SystemDesc: rs.SystemDescription, + Capabilities: parseLLDPCapabilities(rs.SystemCapabilitiesEnabled), + MgmtAddress: mgmt, + }) + } + } + } + + tmplName := "lldp.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// parseLLDPCapabilities turns the YANG system-capabilities-enabled bits +// value into a readable comma-separated string. +func parseLLDPCapabilities(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + // Try plain string first (some implementations encode as "bridge router") + var s string + if json.Unmarshal(raw, &s) == nil { + parts := strings.Fields(s) + return strings.Join(parts, ", ") + } + // Try array of strings + var arr []string + if json.Unmarshal(raw, &arr) == nil { + return strings.Join(arr, ", ") + } + // Fallback: return raw minus braces + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "{}" || trimmed == "null" || trimmed == "[]" { + return "" + } + return trimmed +} diff --git a/src/webui/internal/handlers/ntp.go b/src/webui/internal/handlers/ntp.go new file mode 100644 index 00000000..8e4a40a1 --- /dev/null +++ b/src/webui/internal/handlers/ntp.go @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "strings" + + "github.com/kernelkit/webui/internal/restconf" +) + +// ─── NTP types ─────────────────────────────────────────────────────────────── + +// NTPAssoc is a single NTP association/peer. +type NTPAssoc struct { + Address string + Stratum int + RefID string + Reach string // octal string + Poll int + Offset string // ms + Delay string // ms +} + +// NTPData is the parsed NTP state. +type NTPData struct { + Synchronized bool + Stratum int + RefID string + Offset string // ms + RootDelay string // ms + Associations []NTPAssoc +} + +// ─── Page data ─────────────────────────────────────────────────────────────── + +type ntpPageData struct { + CsrfToken string + PageTitle string + ActivePage string + Capabilities *Capabilities + NTP *NTPData + Error string +} + +// ─── Handler ───────────────────────────────────────────────────────────────── + +// NTPHandler serves the NTP status page. +type NTPHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the NTP page (GET /ntp). +func (h *NTPHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := ntpPageData{ + CsrfToken: csrfToken(r.Context()), + PageTitle: "NTP", + ActivePage: "ntp", + Capabilities: CapabilitiesFromContext(r.Context()), + } + + ctx := context.WithoutCancel(r.Context()) + + var raw struct { + NTP struct { + ClockState struct { + SystemStatus struct { + ClockState string `json:"clock-state"` + ClockStratum int `json:"clock-stratum"` + ClockRefID json.RawMessage `json:"clock-refid"` + ClockOffset yangFloat64 `json:"clock-offset"` + RootDelay yangFloat64 `json:"root-delay"` + } `json:"system-status"` + } `json:"clock-state"` + Associations struct { + Association []struct { + Address string `json:"address"` + Stratum int `json:"stratum"` + RefID json.RawMessage `json:"refid"` + Reach uint8 `json:"reach"` + Poll int `json:"poll"` + Offset yangFloat64 `json:"offset"` + Delay yangFloat64 `json:"delay"` + } `json:"association"` + } `json:"associations"` + } `json:"ietf-ntp:ntp"` + } + if err := h.RC.Get(ctx, "/data/ietf-ntp:ntp", &raw); err != nil { + log.Printf("restconf ntp: %v", err) + data.Error = "Failed to fetch NTP data" + } else { + ss := raw.NTP.ClockState.SystemStatus + synced := strings.Contains(ss.ClockState, "synchronized") && + !strings.Contains(ss.ClockState, "unsynchronized") + ntp := &NTPData{ + Synchronized: synced, + Stratum: ss.ClockStratum, + RefID: rawJSONString(ss.ClockRefID), + Offset: fmt.Sprintf("%.3f ms", float64(ss.ClockOffset)), + RootDelay: fmt.Sprintf("%.3f ms", float64(ss.RootDelay)), + } + for _, a := range raw.NTP.Associations.Association { + ntp.Associations = append(ntp.Associations, NTPAssoc{ + Address: a.Address, + Stratum: a.Stratum, + RefID: rawJSONString(a.RefID), + Reach: fmt.Sprintf("%o", a.Reach), + Poll: a.Poll, + Offset: fmt.Sprintf("%.3f ms", float64(a.Offset)), + Delay: fmt.Sprintf("%.3f ms", float64(a.Delay)), + }) + } + data.NTP = ntp + } + + tmplName := "ntp.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// rawJSONString extracts the unquoted string value from a JSON raw message +// that may be a string, number, or other scalar. +func rawJSONString(b json.RawMessage) string { + if len(b) == 0 { + return "" + } + // Try string + var s string + if json.Unmarshal(b, &s) == nil { + return s + } + // Fall back to raw bytes (number, etc.) + return strings.Trim(string(b), `"`) +} diff --git a/src/webui/internal/handlers/routing.go b/src/webui/internal/handlers/routing.go new file mode 100644 index 00000000..69a983e0 --- /dev/null +++ b/src/webui/internal/handlers/routing.go @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "strings" + "sync" + + "github.com/kernelkit/webui/internal/restconf" +) + +type RouteEntry struct { + DestPrefix string + NextHopIface string + NextHopAddr string + Protocol string + Preference int + Active bool +} + +type OSPFNeighbor struct { + RouterID string + State string + Role string + Interface string + Uptime string +} + +type OSPFIface struct { + Name string + State string + Cost int + DR string + BDR string +} + +type routingData struct { + CsrfToken string + PageTitle string + ActivePage string + Capabilities *Capabilities + Routes []RouteEntry + OSPFNeighbors []OSPFNeighbor + OSPFIfaces []OSPFIface + HasOSPF bool + Error string +} + +type RoutingHandler struct { + Template *template.Template + RC *restconf.Client +} + +type ribWrapper struct { + Routing *struct { + Ribs struct { + Rib []ribJSON `json:"rib"` + } `json:"ribs"` + } `json:"ietf-routing:routing"` + Ribs *struct { + Rib []ribJSON `json:"rib"` + } `json:"ietf-routing:ribs"` +} + +type ribJSON struct { + Name string `json:"name"` + Routes ribRoutesJSON `json:"routes"` +} + +type ribRoutesJSON struct { + Route []ribRouteJSON `json:"route"` +} + +type ribRouteJSON struct { + DestPrefix4 string `json:"ietf-ipv4-unicast-routing:destination-prefix"` + DestPrefix6 string `json:"ietf-ipv6-unicast-routing:destination-prefix"` + DestPrefix string `json:"destination-prefix"` + NextHop ribNextHopJSON `json:"next-hop"` + SourceProtocol string `json:"source-protocol"` + Active *json.RawMessage `json:"active"` + RoutePreference int `json:"route-preference"` +} + +func (r ribRouteJSON) destinationPrefix() string { + if r.DestPrefix4 != "" { + return r.DestPrefix4 + } + if r.DestPrefix6 != "" { + return r.DestPrefix6 + } + return r.DestPrefix +} + +type ribNextHopJSON struct { + OutgoingInterface string `json:"outgoing-interface"` + NextHopAddress string `json:"next-hop-address"` + NextHopList *ribNextHopList `json:"next-hop-list"` +} + +type ribNextHopList struct { + NextHop []ribNextHopEntry `json:"next-hop"` +} + +type ribNextHopEntry struct { + OutgoingInterface string `json:"outgoing-interface"` + Address4 string `json:"ietf-ipv4-unicast-routing:address"` + Address6 string `json:"ietf-ipv6-unicast-routing:address"` + NextHopAddress string `json:"next-hop-address"` +} + +func (nh ribNextHopJSON) resolve() (iface, addr string) { + if nh.OutgoingInterface != "" || nh.NextHopAddress != "" { + return nh.OutgoingInterface, nh.NextHopAddress + } + if nh.NextHopList != nil && len(nh.NextHopList.NextHop) > 0 { + e := nh.NextHopList.NextHop[0] + addr = e.NextHopAddress + if addr == "" { + addr = e.Address4 + } + if addr == "" { + addr = e.Address6 + } + return e.OutgoingInterface, addr + } + return "", "" +} + +type ospfCPPWrapper struct { + Routing struct { + CPP struct { + Protocol []ospfProtocolJSON `json:"control-plane-protocol"` + } `json:"control-plane-protocols"` + } `json:"ietf-routing:routing"` +} + +type ospfProtocolJSON struct { + Type string `json:"type"` + Name string `json:"name"` + OSPF *ospfJSON `json:"ietf-ospf:ospf"` +} + +type ospfJSON struct { + Areas struct { + Area []ospfAreaJSON `json:"area"` + } `json:"areas"` +} + +type ospfAreaJSON struct { + AreaID string `json:"area-id"` + Interfaces struct { + Interface []ospfIfaceJSON `json:"interface"` + } `json:"interfaces"` +} + +type ospfIfaceJSON struct { + Name string `json:"name"` + State string `json:"state"` + Cost int `json:"cost"` + DRRouterID string `json:"dr-router-id"` + BDRRouterID string `json:"bdr-router-id"` + Neighbors struct { + Neighbor []ospfNeighborJSON `json:"neighbor"` + } `json:"neighbors"` +} + +type ospfNeighborJSON struct { + NeighborRouterID string `json:"neighbor-router-id"` + State string `json:"state"` + Role string `json:"infix-routing:role"` + InterfaceName string `json:"infix-routing:interface-name"` + Uptime uint32 `json:"infix-routing:uptime"` +} + +func (h *RoutingHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := routingData{ + CsrfToken: csrfToken(r.Context()), + PageTitle: "Routing", + ActivePage: "routing", + Capabilities: CapabilitiesFromContext(r.Context()), + } + + ctx := context.WithoutCancel(r.Context()) + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + raw, err := h.RC.GetRaw(ctx, "/data/ietf-routing:routing/ribs") + if err != nil { + log.Printf("restconf rib: %v", err) + return + } + var rib ribWrapper + if err := json.Unmarshal(raw, &rib); err != nil { + log.Printf("restconf rib unmarshal: %v", err) + return + } + var ribs []ribJSON + if rib.Routing != nil { + ribs = rib.Routing.Ribs.Rib + } else if rib.Ribs != nil { + ribs = rib.Ribs.Rib + } + for _, rb := range ribs { + for _, route := range rb.Routes.Route { + iface, addr := route.NextHop.resolve() + data.Routes = append(data.Routes, RouteEntry{ + DestPrefix: route.destinationPrefix(), + NextHopIface: iface, + NextHopAddr: addr, + Protocol: shortProto(route.SourceProtocol), + Preference: route.RoutePreference, + Active: route.Active != nil, + }) + } + } + }() + + go func() { + defer wg.Done() + var cpp ospfCPPWrapper + if err := h.RC.Get(ctx, + "/data/ietf-routing:routing/control-plane-protocols", + &cpp); err != nil { + log.Printf("restconf ospf (ignored): %v", err) + return + } + for _, proto := range cpp.Routing.CPP.Protocol { + if proto.OSPF == nil { + continue + } + data.HasOSPF = true + for _, area := range proto.OSPF.Areas.Area { + for _, iface := range area.Interfaces.Interface { + data.OSPFIfaces = append(data.OSPFIfaces, OSPFIface{ + Name: iface.Name, + State: iface.State, + Cost: iface.Cost, + DR: iface.DRRouterID, + BDR: iface.BDRRouterID, + }) + for _, nbr := range iface.Neighbors.Neighbor { + data.OSPFNeighbors = append(data.OSPFNeighbors, OSPFNeighbor{ + RouterID: nbr.NeighborRouterID, + State: nbr.State, + Role: nbr.Role, + Interface: iface.Name, + Uptime: formatUptime(nbr.Uptime), + }) + } + } + } + } + }() + + wg.Wait() + + tmplName := "routing.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +func shortProto(proto string) string { + if i := strings.LastIndex(proto, ":"); i >= 0 { + return proto[i+1:] + } + return proto +} + +func formatUptime(sec uint32) string { + if sec == 0 { + return "" + } + days := sec / 86400 + hours := (sec % 86400) / 3600 + mins := (sec % 3600) / 60 + secs := sec % 60 + switch { + case days > 0: + return fmt.Sprintf("%dd %dh %dm", days, hours, mins) + case hours > 0: + return fmt.Sprintf("%dh %dm", hours, mins) + case mins > 0: + return fmt.Sprintf("%dm %ds", mins, secs) + default: + return fmt.Sprintf("%ds", secs) + } +} diff --git a/src/webui/internal/handlers/routing_test.go b/src/webui/internal/handlers/routing_test.go new file mode 100644 index 00000000..12da62a6 --- /dev/null +++ b/src/webui/internal/handlers/routing_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "html/template" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kernelkit/webui/internal/restconf" + "github.com/kernelkit/webui/internal/security" +) + +var minimalRoutingTmpl = template.Must(template.New("routing.html").Parse( + `{{define "routing.html"}}routes={{len .Routes}}{{end}}` + + `{{define "content"}}{{len .Routes}}{{end}}`, +)) + +func TestRoutingOverview_ReturnsOK(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &RoutingHandler{Template: minimalRoutingTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/routing", nil) + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body") + } +} + +func TestRoutingOverview_HTMXPartial(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &RoutingHandler{Template: minimalRoutingTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/routing", nil) + req.Header.Set("HX-Request", "true") + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body for htmx partial") + } +} diff --git a/src/webui/internal/handlers/services_test.go b/src/webui/internal/handlers/services_test.go new file mode 100644 index 00000000..0c6abc6d --- /dev/null +++ b/src/webui/internal/handlers/services_test.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "html/template" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kernelkit/webui/internal/restconf" + "github.com/kernelkit/webui/internal/security" +) + +var minimalDHCPTmpl = template.Must(template.New("dhcp.html").Parse( + `{{define "dhcp.html"}}dhcp={{.DHCP}}{{end}}` + + `{{define "content"}}{{.DHCP}}{{end}}`, +)) + +var minimalNTPTmpl = template.Must(template.New("ntp.html").Parse( + `{{define "ntp.html"}}ntp={{.NTP}}{{end}}` + + `{{define "content"}}{{.NTP}}{{end}}`, +)) + +var minimalLLDPTmpl = template.Must(template.New("lldp.html").Parse( + `{{define "lldp.html"}}lldp={{.Neighbors}}{{end}}` + + `{{define "content"}}{{.Neighbors}}{{end}}`, +)) + +func testCtx(req *http.Request) *http.Request { + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + return req.WithContext(ctx) +} + +func TestDHCPOverview_ReturnsOK(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &DHCPHandler{Template: minimalDHCPTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/dhcp", nil) + req = testCtx(req) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + if w.Body.String() == "" { + t.Error("expected non-empty response body") + } +} + +func TestDHCPOverview_HTMXPartial(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &DHCPHandler{Template: minimalDHCPTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/dhcp", nil) + req.Header.Set("HX-Request", "true") + req = testCtx(req) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + if w.Body.String() == "" { + t.Error("expected non-empty response body for htmx partial") + } +} + +func TestNTPOverview_ReturnsOK(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &NTPHandler{Template: minimalNTPTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/ntp", nil) + req = testCtx(req) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + if w.Body.String() == "" { + t.Error("expected non-empty response body") + } +} + +func TestLLDPOverview_ReturnsOK(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &LLDPHandler{Template: minimalLLDPTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/lldp", nil) + req = testCtx(req) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + if w.Body.String() == "" { + t.Error("expected non-empty response body") + } +} diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go new file mode 100644 index 00000000..3f9664d9 --- /dev/null +++ b/src/webui/internal/handlers/system.go @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "fmt" + "html/template" + "log" + "net/http" + + "github.com/kernelkit/webui/internal/restconf" +) + +// SystemHandler provides reboot, config download, and firmware update actions. +type SystemHandler struct { + RC *restconf.Client + Template *template.Template // firmware page template +} + +// DeviceStatus returns 200 if the RESTCONF device is reachable, 502 otherwise. +// Used by the reboot spinner to detect when the device goes down and comes back. +func (h *SystemHandler) DeviceStatus(w http.ResponseWriter, r *http.Request) { + var target struct{} + err := h.RC.Get(r.Context(), "/data/ietf-system:system-state/platform", &target) + if err != nil { + w.WriteHeader(http.StatusBadGateway) + return + } + w.WriteHeader(http.StatusOK) +} + +// Reboot triggers a device restart via the ietf-system:system-restart RPC +// and returns a spinner fragment that polls until the device is back. +func (h *SystemHandler) Reboot(w http.ResponseWriter, r *http.Request) { + err := h.RC.Post(r.Context(), "/operations/ietf-system:system-restart") + if err != nil { + log.Printf("reboot: %v", err) + http.Error(w, "reboot failed", http.StatusBadGateway) + return + } + + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, rebootSpinnerHTML) +} + +const rebootSpinnerHTML = `
+
+

Rebooting…

+

Waiting for device to shut down…

+
` + +// DownloadConfig serves the startup datastore as a JSON file download. +func (h *SystemHandler) DownloadConfig(w http.ResponseWriter, r *http.Request) { + data, err := h.RC.GetRaw(r.Context(), "/ds/ietf-datastores:startup") + if err != nil { + log.Printf("config download: %v", err) + http.Error(w, "failed to fetch config", http.StatusBadGateway) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", `attachment; filename="startup-config.json"`) + w.Write(data) +} + +// RESTCONF JSON structures for infix-system:software state. + +type fwSoftwareWrapper struct { + SystemState struct { + Software fwSoftwareState `json:"infix-system:software"` + } `json:"ietf-system:system-state"` +} + +type fwSoftwareState struct { + Compatible string `json:"compatible"` + Variant string `json:"variant"` + Booted string `json:"booted"` + Installer fwInstallerState `json:"installer"` + Slots []fwSlot `json:"slot"` +} + +type fwInstallerState struct { + Operation string `json:"operation"` + Progress fwInstallerProgress `json:"progress"` + LastError string `json:"last-error"` +} + +type fwInstallerProgress struct { + Percentage int `json:"percentage"` + Message string `json:"message"` +} + +type fwSlot struct { + Name string `json:"name"` + BootName string `json:"bootname"` + Class string `json:"class"` + State string `json:"state"` + Bundle fwSlotBundle `json:"bundle"` +} + +type fwSlotBundle struct { + Compatible string `json:"compatible"` + Version string `json:"version"` +} + +// Template data for the firmware page. + +type firmwareData struct { + CsrfToken string + Username string + ActivePage string + PageTitle string + Capabilities *Capabilities + Slots []slotEntry + Installer *installerEntry + Error string + Message string +} + +type slotEntry struct { + Name string + BootName string + State string + Version string + Booted bool +} + +type installerEntry struct { + Operation string + Percentage int + Message string + LastError string + Active bool +} + +// Firmware renders the firmware overview page (GET /firmware). +func (h *SystemHandler) Firmware(w http.ResponseWriter, r *http.Request) { + creds := restconf.CredentialsFromContext(r.Context()) + data := firmwareData{ + Username: creds.Username, + CsrfToken: csrfToken(r.Context()), + ActivePage: "firmware", + PageTitle: "Firmware", + Capabilities: CapabilitiesFromContext(r.Context()), + Message: r.URL.Query().Get("msg"), + } + + var sw fwSoftwareWrapper + err := h.RC.Get(r.Context(), "/data/ietf-system:system-state", &sw) + if err != nil { + log.Printf("restconf firmware: %v", err) + data.Error = "Could not fetch firmware status" + } else { + for _, s := range sw.SystemState.Software.Slots { + data.Slots = append(data.Slots, slotEntry{ + Name: s.Name, + BootName: s.BootName, + State: s.State, + Version: s.Bundle.Version, + Booted: s.Name == sw.SystemState.Software.Booted, + }) + } + + inst := sw.SystemState.Software.Installer + data.Installer = &installerEntry{ + Operation: inst.Operation, + Percentage: inst.Progress.Percentage, + Message: inst.Progress.Message, + LastError: inst.LastError, + Active: inst.Operation != "" && inst.Operation != "idle", + } + } + + tmplName := "firmware.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// FirmwareInstall triggers a firmware install via the install-bundle RPC (POST /firmware/install). +func (h *SystemHandler) FirmwareInstall(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + url := r.FormValue("url") + if url == "" { + http.Error(w, "url is required", http.StatusBadRequest) + return + } + + body := map[string]map[string]string{ + "infix-system:input": { + "url": url, + }, + } + + err := h.RC.PostJSON(r.Context(), "/operations/infix-system:install-bundle", body) + if err != nil { + log.Printf("firmware install: %v", err) + w.Header().Set("HX-Redirect", "/firmware?msg=Install+failed:+"+err.Error()) + w.WriteHeader(http.StatusNoContent) + return + } + + w.Header().Set("HX-Redirect", "/firmware?msg=Install+started") + w.WriteHeader(http.StatusNoContent) +} diff --git a/src/webui/internal/handlers/vpn.go b/src/webui/internal/handlers/vpn.go new file mode 100644 index 00000000..6def10e9 --- /dev/null +++ b/src/webui/internal/handlers/vpn.go @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "fmt" + "html/template" + "log" + "net/http" + "sync" + "time" + + "github.com/kernelkit/webui/internal/restconf" +) + +// wgConfigJSON is a local extension for fetching WireGuard configuration +// fields (like listen-port) that are not in the shared wireGuardJSON type. +type wgConfigJSON struct { + ListenPort int `json:"listen-port"` +} + +// wgIfaceConfigWrapper is used to fetch per-interface WireGuard config. +type wgIfaceConfigWrapper struct { + WireGuard *wgConfigJSON `json:"infix-interfaces:wireguard"` +} + +// WGPeer holds display-ready data for a single WireGuard peer. +type WGPeer struct { + PublicKey string + PublicKeyShort string // first 8 chars + "..." + Endpoint string // "IP:port" or empty + Status string // "up" or "down" + LastHandshake string // relative time, e.g. "2 min ago" or "never" + RxBytes string // human-readable + TxBytes string // human-readable +} + +// WGTunnel holds display-ready data for a single WireGuard interface. +type WGTunnel struct { + Name string + ListenPort int + Addresses []string // IP addresses from ietf-ip + OperStatus string + Peers []WGPeer +} + +// vpnData is the template data struct for the VPN page. +type vpnData struct { + CsrfToken string + PageTitle string + ActivePage string + Capabilities *Capabilities + Tunnels []WGTunnel + Error string +} + +// VPNHandler serves the VPN/WireGuard status page. +type VPNHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the VPN page (GET /vpn). +func (h *VPNHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := vpnData{ + CsrfToken: csrfToken(r.Context()), + PageTitle: "VPN", + ActivePage: "vpn", + Capabilities: CapabilitiesFromContext(r.Context()), + } + + // Detach from the request context so that RESTCONF calls survive + // browser connection resets (common during login redirects). + ctx := context.WithoutCancel(r.Context()) + + var ifaces interfacesWrapper + if err := h.RC.Get(ctx, "/data/ietf-interfaces:interfaces", &ifaces); err != nil { + log.Printf("restconf interfaces (vpn): %v", err) + data.Error = "Could not fetch interface information" + h.render(w, r, data) + return + } + + // Filter for WireGuard interfaces only. + var wgIfaces []ifaceJSON + for _, iface := range ifaces.Interfaces.Interface { + if iface.Type == "infix-if-type:wireguard" { + wgIfaces = append(wgIfaces, iface) + } + } + + if len(wgIfaces) == 0 { + h.render(w, r, data) + return + } + + // Fetch per-interface WireGuard config concurrently. + tunnels := make([]WGTunnel, len(wgIfaces)) + var wg sync.WaitGroup + + for i, iface := range wgIfaces { + wg.Add(1) + go func(idx int, iface ifaceJSON) { + defer wg.Done() + tunnels[idx] = buildWGTunnel(ctx, h.RC, iface) + }(i, iface) + } + + wg.Wait() + data.Tunnels = tunnels + h.render(w, r, data) +} + +// buildWGTunnel constructs a WGTunnel from an interface and optional config fetch. +func buildWGTunnel(ctx context.Context, rc *restconf.Client, iface ifaceJSON) WGTunnel { + tunnel := WGTunnel{ + Name: iface.Name, + OperStatus: iface.OperStatus, + } + + // Collect IP addresses. + if iface.IPv4 != nil { + for _, a := range iface.IPv4.Address { + tunnel.Addresses = append(tunnel.Addresses, fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength))) + } + } + if iface.IPv6 != nil { + for _, a := range iface.IPv6.Address { + tunnel.Addresses = append(tunnel.Addresses, fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength))) + } + } + + // Fetch ListenPort from config endpoint (separate from oper-state). + var cfgWrap wgIfaceConfigWrapper + path := fmt.Sprintf("/data/ietf-interfaces:interfaces/interface=%s/infix-interfaces:wireguard", iface.Name) + if err := rc.Get(ctx, path, &cfgWrap); err == nil && cfgWrap.WireGuard != nil { + tunnel.ListenPort = cfgWrap.WireGuard.ListenPort + } + + // Build peers from embedded peer-status. + if iface.WireGuard != nil && iface.WireGuard.PeerStatus != nil { + for _, p := range iface.WireGuard.PeerStatus.Peer { + peer := WGPeer{ + PublicKey: p.PublicKey, + PublicKeyShort: shortKey(p.PublicKey), + Status: p.ConnectionStatus, + LastHandshake: relativeTime(p.LatestHandshake), + } + if p.EndpointAddress != "" { + peer.Endpoint = fmt.Sprintf("%s:%d", p.EndpointAddress, p.EndpointPort) + } + if p.Transfer != nil { + peer.RxBytes = humanBytes(int64(p.Transfer.RxBytes)) + peer.TxBytes = humanBytes(int64(p.Transfer.TxBytes)) + } else { + peer.RxBytes = "0 B" + peer.TxBytes = "0 B" + } + tunnel.Peers = append(tunnel.Peers, peer) + } + } + + return tunnel +} + +// shortKey returns the first 8 characters of a WireGuard public key followed by "...". +func shortKey(key string) string { + if len(key) <= 8 { + return key + } + return key[:8] + "..." +} + +// relativeTime converts an RFC3339 timestamp to a human-readable relative time string. +// Returns "never" if the timestamp is empty or cannot be parsed. +func relativeTime(ts string) string { + if ts == "" { + return "never" + } + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + // Try RFC3339Nano as fallback. + t, err = time.Parse(time.RFC3339Nano, ts) + if err != nil { + return "never" + } + } + if t.IsZero() { + return "never" + } + + d := time.Since(t) + switch { + case d < 0: + return "just now" + case d < time.Minute: + return fmt.Sprintf("%d sec ago", int(d.Seconds())) + case d < time.Hour: + mins := int(d.Minutes()) + if mins == 1 { + return "1 min ago" + } + return fmt.Sprintf("%d min ago", mins) + case d < 24*time.Hour: + hours := int(d.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + default: + days := int(d.Hours()) / 24 + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + } +} + +// render executes the correct template based on whether it's an htmx request. +func (h *VPNHandler) render(w http.ResponseWriter, r *http.Request, data vpnData) { + tmplName := "vpn.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error (vpn): %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} diff --git a/src/webui/internal/handlers/vpn_test.go b/src/webui/internal/handlers/vpn_test.go new file mode 100644 index 00000000..62ad57af --- /dev/null +++ b/src/webui/internal/handlers/vpn_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "html/template" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kernelkit/webui/internal/restconf" + "github.com/kernelkit/webui/internal/security" +) + +var minimalVPNTmpl = template.Must(template.New("vpn.html").Parse( + `{{define "vpn.html"}}tunnels={{len .Tunnels}}{{end}}` + + `{{define "content"}}{{len .Tunnels}}{{end}}`, +)) + +func TestVPNOverview_ReturnsOK(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &VPNHandler{Template: minimalVPNTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/vpn", nil) + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body") + } +} + +func TestVPNOverview_HTMXPartial(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &VPNHandler{Template: minimalVPNTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/vpn", nil) + req.Header.Set("HX-Request", "true") + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body for htmx partial") + } +} diff --git a/src/webui/internal/handlers/wifi.go b/src/webui/internal/handlers/wifi.go new file mode 100644 index 00000000..89c80cf9 --- /dev/null +++ b/src/webui/internal/handlers/wifi.go @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "context" + "fmt" + "html/template" + "log" + "net/http" + "sync" + + "github.com/kernelkit/webui/internal/restconf" +) + +// wifiRadioHWJSON extends the hardware component wifi-radio container with +// operational fields from infix-hardware YANG that are not in wifiRadioJSON. +// wifiRadioJSON (defined in interfaces.go) only covers survey data; this +// struct captures the full operational state returned by RESTCONF. +// wifiMaxIfJSON maps the max-interfaces container from infix-hardware YANG. +type wifiMaxIfJSON struct { + AP int `json:"ap"` + Station int `json:"station"` + Monitor int `json:"monitor"` +} + +type wifiRadioHWJSON struct { + Channel interface{} `json:"channel"` // uint16 or "auto" + Band string `json:"band"` + Frequency int `json:"frequency"` // MHz, operational + Noise int `json:"noise"` // dBm, operational + Driver string `json:"driver"` + Bands []wifiBandJSON `json:"bands"` + MaxInterfaces *wifiMaxIfJSON `json:"max-interfaces"` + Survey *wifiSurveyJSON `json:"survey"` +} + +type wifiBandJSON struct { + Band string `json:"band"` + Name string `json:"name"` + HTCapable bool `json:"ht-capable"` + VHTCapable bool `json:"vht-capable"` + HECapable bool `json:"he-capable"` +} + +// hwComponentWiFiJSON is a minimal hardware component used for the wifi page. +// We reuse hardwareWrapper but need to decode wifi-radio with the richer struct. +type hwComponentWiFiJSON struct { + Name string `json:"name"` + Class string `json:"class"` + MfgName string `json:"mfg-name"` + WiFiRadio *wifiRadioHWJSON `json:"infix-hardware:wifi-radio"` +} + +type hardwareWiFiWrapper struct { + Hardware struct { + Component []hwComponentWiFiJSON `json:"component"` + } `json:"ietf-hardware:hardware"` +} + +// WiFiRadio is the template data for a single physical radio. +type WiFiRadio struct { + Name string + Channel string + Band string + Frequency int + Noise int + Driver string + Manufacturer string + Standards string + MaxAP string + HTCapable bool + VHTCapable bool + HECapable bool + Bands []WiFiBand + SurveySVG template.HTML + Interfaces []WiFiInterface +} + +type WiFiBand struct { + Band string + Name string + HTCapable bool + VHTCapable bool + HECapable bool +} + +// ChannelSurvey holds processed survey data for one channel. +type ChannelSurvey struct { + Frequency int + Channel int + InUse bool + Noise int + ActiveTime int64 + BusyTime int64 + UtilPct int // BusyTime/ActiveTime * 100 +} + +// WiFiInterface is the template data for a virtual WiFi interface. +type WiFiInterface struct { + Name string + Mode string // "ap" or "station" + SSID string + OperStatus string + StatusUp bool + // AP mode + APClients []WiFiClient + // Station mode + Signal string + SignalCSS string + RxSpeed string + TxSpeed string + ScanResults []WiFiScan +} + +// WiFiClient is the template data for a connected station client. +type WiFiClient struct { + MAC string + Signal string + SignalCSS string + ConnTime string + RxBytes string + TxBytes string + RxSpeed string + TxSpeed string +} + +// WiFiScan is the template data for a scan result entry. +type WiFiScan struct { + SSID string + BSSID string + Signal string + SignalCSS string + Channel string + Encryption string +} + +// wifiData is the top-level template data for the WiFi page. +type wifiData struct { + CsrfToken string + PageTitle string + ActivePage string + Capabilities *Capabilities + Radios []WiFiRadio + Error string +} + +// WiFiHandler serves the WiFi status page. +type WiFiHandler struct { + Template *template.Template + RC *restconf.Client +} + +// Overview renders the WiFi page (GET /wifi). +func (h *WiFiHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := wifiData{ + CsrfToken: csrfToken(r.Context()), + PageTitle: "WiFi", + ActivePage: "wifi", + Capabilities: CapabilitiesFromContext(r.Context()), + } + + // Detach from the request context so that RESTCONF calls survive + // browser connection resets. + ctx := context.WithoutCancel(r.Context()) + + var ( + hw hardwareWiFiWrapper + ifaces interfacesWrapper + hwErr, ifErr error + wg sync.WaitGroup + ) + + wg.Add(2) + go func() { + defer wg.Done() + hwErr = h.RC.Get(ctx, "/data/ietf-hardware:hardware", &hw) + }() + go func() { + defer wg.Done() + ifErr = h.RC.Get(ctx, "/data/ietf-interfaces:interfaces", &ifaces) + }() + wg.Wait() + + if hwErr != nil { + log.Printf("wifi: restconf hardware: %v", hwErr) + data.Error = "Could not fetch hardware information" + } + if ifErr != nil { + log.Printf("wifi: restconf interfaces: %v", ifErr) + if data.Error == "" { + data.Error = "Could not fetch interface information" + } + } + + if hwErr == nil && ifErr == nil { + data.Radios = buildWiFiRadios(hw.Hardware.Component, ifaces.Interfaces.Interface) + } + + tmplName := "wifi.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("wifi: template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// buildWiFiRadios assembles the WiFiRadio slice from hardware components +// and interface data, matching interfaces to their radio by name. +func buildWiFiRadios(components []hwComponentWiFiJSON, ifaces []ifaceJSON) []WiFiRadio { + var radios []WiFiRadio + + for _, c := range components { + if c.WiFiRadio == nil { + continue + } + r := c.WiFiRadio + + radio := WiFiRadio{ + Name: c.Name, + Band: r.Band, + Frequency: r.Frequency, + Noise: r.Noise, + Driver: r.Driver, + Channel: wifiChannelString(r.Channel), + Manufacturer: c.MfgName, + } + + // Capability flags: check per-band capabilities; if any band supports + // HT/VHT/HE, mark the radio as capable. + var bandNames []string + for _, b := range r.Bands { + name := b.Name + if name == "" { + name = b.Band + } + radio.Bands = append(radio.Bands, WiFiBand{ + Band: b.Band, + Name: name, + HTCapable: b.HTCapable, + VHTCapable: b.VHTCapable, + HECapable: b.HECapable, + }) + if b.Name != "" { + bandNames = append(bandNames, b.Name) + } + if b.HTCapable { + radio.HTCapable = true + } + if b.VHTCapable { + radio.VHTCapable = true + } + if b.HECapable { + radio.HECapable = true + } + } + + // Derive standards string from aggregated capabilities (matches CLI). + var standards []string + if radio.HTCapable { + standards = append(standards, "11n") + } + if radio.VHTCapable { + standards = append(standards, "11ac") + } + if radio.HECapable { + standards = append(standards, "11ax") + } + if len(standards) > 0 { + radio.Standards = joinStrings(standards, "/") + } + + // Max AP count from max-interfaces container. + if r.MaxInterfaces != nil && r.MaxInterfaces.AP > 0 { + radio.MaxAP = fmt.Sprintf("%d", r.MaxInterfaces.AP) + } + + // Generate channel survey SVG if survey data exists. + if r.Survey != nil && len(r.Survey.Channel) > 0 { + radio.SurveySVG = renderSurveySVG(r.Survey.Channel) + } + + // Attach wifi interfaces that reference this radio. + radio.Interfaces = buildWiFiInterfaces(c.Name, ifaces) + + radios = append(radios, radio) + } + + return radios +} + +// buildWiFiInterfaces returns the WiFiInterface entries for all virtual +// interfaces that reference the given radio name. +func buildWiFiInterfaces(radioName string, ifaces []ifaceJSON) []WiFiInterface { + var result []WiFiInterface + + for _, iface := range ifaces { + if iface.WiFi == nil || iface.WiFi.Radio != radioName { + continue + } + + wi := WiFiInterface{ + Name: iface.Name, + OperStatus: iface.OperStatus, + StatusUp: iface.OperStatus == "up", + } + + if ap := iface.WiFi.AccessPoint; ap != nil { + wi.Mode = "ap" + wi.SSID = ap.SSID + for _, s := range ap.Stations.Station { + wi.APClients = append(wi.APClients, buildWiFiClient(s)) + } + } else if st := iface.WiFi.Station; st != nil { + wi.Mode = "station" + wi.SSID = st.SSID + if st.SignalStrength != nil { + sig := *st.SignalStrength + wi.Signal = fmt.Sprintf("%d dBm", sig) + wi.SignalCSS = wifiSignalCSS(sig) + } + if st.RxSpeed > 0 { + wi.RxSpeed = fmt.Sprintf("%.1f Mbps", float64(st.RxSpeed)/10) + } + if st.TxSpeed > 0 { + wi.TxSpeed = fmt.Sprintf("%.1f Mbps", float64(st.TxSpeed)/10) + } + for _, sr := range st.ScanResults { + wi.ScanResults = append(wi.ScanResults, buildWiFiScan(sr)) + } + } + + result = append(result, wi) + } + + return result +} + +// buildWiFiClient converts a wifiStaJSON to a WiFiClient template entry. +func buildWiFiClient(s wifiStaJSON) WiFiClient { + c := WiFiClient{ + MAC: s.MACAddress, + ConnTime: formatDuration(int64(s.ConnectedTime)), + RxBytes: humanBytes(int64(s.RxBytes)), + TxBytes: humanBytes(int64(s.TxBytes)), + RxSpeed: fmt.Sprintf("%.1f Mbps", float64(s.RxSpeed)/10), + TxSpeed: fmt.Sprintf("%.1f Mbps", float64(s.TxSpeed)/10), + } + if s.SignalStrength != nil { + sig := *s.SignalStrength + c.Signal = fmt.Sprintf("%d dBm", sig) + c.SignalCSS = wifiSignalCSS(sig) + } + return c +} + +// buildWiFiScan converts a wifiScanResultJSON to a WiFiScan template entry. +func buildWiFiScan(sr wifiScanResultJSON) WiFiScan { + enc := "Open" + if len(sr.Encryption) > 0 { + parts := make([]string, 0, len(sr.Encryption)) + for _, e := range sr.Encryption { + parts = append(parts, e) + } + enc = joinStrings(parts, ", ") + } + s := WiFiScan{ + SSID: sr.SSID, + BSSID: sr.BSSID, + Channel: fmt.Sprintf("%d", sr.Channel), + Encryption: enc, + } + if sr.SignalStrength != nil { + sig := *sr.SignalStrength + s.Signal = fmt.Sprintf("%d dBm", sig) + s.SignalCSS = wifiSignalCSS(sig) + } + return s +} + +// wifiSignalCSS returns a CSS class based on signal strength in dBm. +func wifiSignalCSS(sig int) string { + switch { + case sig >= -50: + return "signal-excellent" + case sig >= -60: + return "signal-good" + case sig >= -70: + return "signal-ok" + default: + return "signal-poor" + } +} + +// wifiChannelString converts the YANG channel union value to a display string. +// The JSON may arrive as a float64 (number) or string ("auto"). +func wifiChannelString(v interface{}) string { + if v == nil { + return "" + } + switch val := v.(type) { + case string: + return val + case float64: + return fmt.Sprintf("%d", int(val)) + case int: + return fmt.Sprintf("%d", val) + default: + return fmt.Sprintf("%v", val) + } +} + +// joinStrings joins a slice of strings with a separator. +func joinStrings(parts []string, sep string) string { + result := "" + for i, p := range parts { + if i > 0 { + result += sep + } + result += p + } + return result +} diff --git a/src/webui/internal/handlers/wifi_test.go b/src/webui/internal/handlers/wifi_test.go new file mode 100644 index 00000000..02947a4a --- /dev/null +++ b/src/webui/internal/handlers/wifi_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "html/template" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kernelkit/webui/internal/restconf" + "github.com/kernelkit/webui/internal/security" +) + +var minimalWiFiTmpl = template.Must(template.New("wifi.html").Parse( + `{{define "wifi.html"}}radios={{len .Radios}}{{end}}` + + `{{define "content"}}{{len .Radios}}{{end}}`, +)) + +func TestWiFiOverview_ReturnsOK(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &WiFiHandler{Template: minimalWiFiTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/wifi", nil) + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body") + } +} + +func TestWiFiOverview_HTMXPartial(t *testing.T) { + rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false) + h := &WiFiHandler{Template: minimalWiFiTmpl, RC: rc} + + req := httptest.NewRequest(http.MethodGet, "/wifi", nil) + req.Header.Set("HX-Request", "true") + ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{ + Username: "admin", + Password: "admin", + }) + ctx = security.WithToken(ctx, "test-csrf-token") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + h.Overview(w, req) + + if w.Code != http.StatusOK { + t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if body == "" { + t.Error("expected non-empty response body for htmx partial") + } +} diff --git a/src/webui/internal/restconf/client.go b/src/webui/internal/restconf/client.go new file mode 100644 index 00000000..4c8e45b3 --- /dev/null +++ b/src/webui/internal/restconf/client.go @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: MIT + +package restconf + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Credentials holds username/password for Basic Auth. +// Stored in request contexts by the auth middleware. +type Credentials struct { + Username string + Password string +} + +type ctxKey struct{} + +// ContextWithCredentials returns a child context carrying creds. +func ContextWithCredentials(ctx context.Context, c Credentials) context.Context { + return context.WithValue(ctx, ctxKey{}, c) +} + +// CredentialsFromContext extracts credentials set by the auth middleware. +func CredentialsFromContext(ctx context.Context) Credentials { + c, _ := ctx.Value(ctxKey{}).(Credentials) + return c +} + +// Client talks to the rousette RESTCONF server. +type Client struct { + baseURL string + httpClient *http.Client +} + +// NewClient creates a RESTCONF client pointing at baseURL +// (e.g. "https://192.168.1.1/restconf" or "https://127.0.0.1/restconf"). +// When insecureTLS is true, TLS certificate verification is disabled. +func NewClient(baseURL string, insecureTLS bool) *Client { + var tlsConfig *tls.Config + if insecureTLS { + tlsConfig = &tls.Config{InsecureSkipVerify: true} + } + return &Client{ + baseURL: strings.TrimRight(escapeZoneID(baseURL), "/"), + httpClient: &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + }, + } +} + +// Get fetches a RESTCONF resource, decoding the JSON response into target. +// User credentials are taken from the request context (set by auth middleware). +func (c *Client) Get(ctx context.Context, path string, target any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return err + } + + req.Header.Set("Accept", "application/yang-data+json") + + creds := CredentialsFromContext(ctx) + req.SetBasicAuth(creds.Username, creds.Password) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("restconf request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return parseError(resp) + } + + return json.NewDecoder(resp.Body).Decode(target) +} + +// Post sends a POST request to a RESTCONF RPC endpoint. +// Used for operations like system-restart that return no body. +func (c *Client) Post(ctx context.Context, path string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, nil) + if err != nil { + return err + } + + req.Header.Set("Accept", "application/yang-data+json") + + creds := CredentialsFromContext(ctx) + req.SetBasicAuth(creds.Username, creds.Password) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("restconf request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return parseError(resp) + } + + return nil +} + +// PostJSON sends a POST request with a JSON body to a RESTCONF RPC endpoint. +// Used for RPCs that require input parameters (e.g. install-bundle). +func (c *Client) PostJSON(ctx context.Context, path string, body any) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(body); err != nil { + return fmt.Errorf("encoding request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, &buf) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/yang-data+json") + req.Header.Set("Accept", "application/yang-data+json") + + creds := CredentialsFromContext(ctx) + req.SetBasicAuth(creds.Username, creds.Password) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("restconf request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return parseError(resp) + } + + return nil +} + +// GetRaw fetches a RESTCONF resource and returns the raw JSON bytes. +func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/yang-data+json") + + creds := CredentialsFromContext(ctx) + req.SetBasicAuth(creds.Username, creds.Password) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("restconf request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, parseError(resp) + } + + return io.ReadAll(resp.Body) +} + +// CheckAuth verifies that the given credentials are accepted by rousette. +// It does a simple GET against /data/ietf-system:system with Basic Auth. +func (c *Client) CheckAuth(username, password string) error { + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodGet, + c.baseURL+"/data/ietf-system:system", + nil, + ) + if err != nil { + return err + } + + req.Header.Set("Accept", "application/yang-data+json") + req.SetBasicAuth(username, password) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("restconf request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return &AuthError{Code: resp.StatusCode} + } + if resp.StatusCode != http.StatusOK { + return parseError(resp) + } + + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "yang-data+json") { + return fmt.Errorf("unexpected content-type from RESTCONF server: %q", ct) + } + + return nil +} + +// escapeZoneID replaces bare "%" in IPv6 zone IDs with "%25" so that +// Go's url.Parse doesn't reject them as invalid percent-encoding. +// e.g. "https://[ff02::1%qtap1]/restconf" → "https://[ff02::1%25qtap1]/restconf" +func escapeZoneID(rawURL string) string { + open := strings.Index(rawURL, "[") + close := strings.Index(rawURL, "]") + if open < 0 || close < 0 || close < open { + return rawURL + } + + host := rawURL[open:close] + if pct := strings.Index(host, "%"); pct >= 0 && !strings.HasPrefix(host[pct:], "%25") { + return rawURL[:open+pct] + "%25" + rawURL[open+pct+1:] + } + return rawURL +} diff --git a/src/webui/internal/restconf/errors.go b/src/webui/internal/restconf/errors.go new file mode 100644 index 00000000..8774a67f --- /dev/null +++ b/src/webui/internal/restconf/errors.go @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT + +package restconf + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +// AuthError is returned when RESTCONF rejects credentials (401/403). +type AuthError struct { + Code int +} + +func (e *AuthError) Error() string { + return fmt.Sprintf("authentication failed (HTTP %d)", e.Code) +} + +// Error represents a RESTCONF error response. +type Error struct { + StatusCode int + Type string + Tag string + Message string +} + +func (e *Error) Error() string { + if e.Message != "" { + return fmt.Sprintf("restconf %d: %s", e.StatusCode, e.Message) + } + return fmt.Sprintf("restconf %d: %s", e.StatusCode, e.Tag) +} + +// parseError reads a RESTCONF error response body and returns an *Error. +func parseError(resp *http.Response) error { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + + re := &Error{StatusCode: resp.StatusCode} + + // Try to parse the standard RESTCONF error envelope. + var envelope struct { + Errors struct { + Error []struct { + ErrorType string `json:"error-type"` + ErrorTag string `json:"error-tag"` + ErrorMessage string `json:"error-message"` + } `json:"error"` + } `json:"ietf-restconf:errors"` + } + + if json.Unmarshal(body, &envelope) == nil && len(envelope.Errors.Error) > 0 { + first := envelope.Errors.Error[0] + re.Type = first.ErrorType + re.Tag = first.ErrorTag + re.Message = first.ErrorMessage + } else { + re.Message = http.StatusText(resp.StatusCode) + } + + return re +} diff --git a/src/webui/internal/restconf/fetcher.go b/src/webui/internal/restconf/fetcher.go new file mode 100644 index 00000000..a06f57a6 --- /dev/null +++ b/src/webui/internal/restconf/fetcher.go @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT + +package restconf + +import "context" + +// Fetcher is the RESTCONF client interface used by handlers. +// *Client satisfies this interface; testutil.MockFetcher provides a test double. +type Fetcher interface { + Get(ctx context.Context, path string, target any) error + GetRaw(ctx context.Context, path string) ([]byte, error) + Post(ctx context.Context, path string) error + PostJSON(ctx context.Context, path string, body any) error +} + +var _ Fetcher = (*Client)(nil) diff --git a/src/webui/internal/security/csrf.go b/src/webui/internal/security/csrf.go new file mode 100644 index 00000000..91a0232a --- /dev/null +++ b/src/webui/internal/security/csrf.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT + +package security + +import ( + "context" + "crypto/rand" + "encoding/base64" + "net/http" + "strings" +) + +const csrfCookieName = "csrf" + +type csrfKey struct{} + +// EnsureToken sets a CSRF cookie if missing (or preferred is provided) +// and returns the current token. +func EnsureToken(w http.ResponseWriter, r *http.Request, preferred string) string { + if token := strings.TrimSpace(preferred); token != "" { + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: IsSecureRequest(r), + SameSite: http.SameSiteStrictMode, + }) + return token + } + + if c, err := r.Cookie(csrfCookieName); err == nil { + if token := strings.TrimSpace(c.Value); validToken(token) { + return token + } + } + + token := randomToken() + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: IsSecureRequest(r), + SameSite: http.SameSiteStrictMode, + }) + return token +} + +// WithToken stores the token in the request context. +func WithToken(ctx context.Context, token string) context.Context { + return context.WithValue(ctx, csrfKey{}, token) +} + +// TokenFromContext returns the CSRF token from context, if set. +func TokenFromContext(ctx context.Context) string { + if v, ok := ctx.Value(csrfKey{}).(string); ok { + return v + } + return "" +} + +func randomToken() string { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "" + } + return base64.RawURLEncoding.EncodeToString(b[:]) +} + +func validToken(token string) bool { + if token == "" { + return false + } + _, err := base64.RawURLEncoding.DecodeString(token) + return err == nil +} + +// ClearToken removes the CSRF cookie. +func ClearToken(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: IsSecureRequest(r), + SameSite: http.SameSiteStrictMode, + }) +} + +// IsSecureRequest returns true for TLS or proxy-terminated HTTPS. +func IsSecureRequest(r *http.Request) bool { + if r.TLS != nil { + return true + } + if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" { + parts := strings.Split(xf, ",") + return strings.EqualFold(strings.TrimSpace(parts[0]), "https") + } + return false +} diff --git a/src/webui/internal/server/middleware.go b/src/webui/internal/server/middleware.go new file mode 100644 index 00000000..31dff8e4 --- /dev/null +++ b/src/webui/internal/server/middleware.go @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT + +package server + +import ( + "net/http" + "net/url" + "strings" + + "github.com/kernelkit/webui/internal/auth" + "github.com/kernelkit/webui/internal/handlers" + "github.com/kernelkit/webui/internal/restconf" + "github.com/kernelkit/webui/internal/security" +) + +const cookieName = "session" + +// authMiddleware checks the session cookie on every request, looks up +// the session, and attaches decrypted 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 { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isPublicPath(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + + cookie, err := r.Cookie(cookieName) + if err != nil { + deny(w, r) + return + } + + username, password, csrf, features, ok := store.Lookup(cookie.Value) + if !ok { + deny(w, r) + return + } + + // Sliding window: re-issue the cookie with a fresh timestamp. + // Skip for background polling endpoints so they don't keep + // the session alive indefinitely. + if !isPollingPath(r.URL.Path) { + if fresh, err := store.CreateWithCSRF(username, password, csrf, features); err == nil { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: fresh, + Path: "/", + HttpOnly: true, + Secure: security.IsSecureRequest(r), + SameSite: http.SameSiteLaxMode, + }) + } + } + + ctx := restconf.ContextWithCredentials(r.Context(), restconf.Credentials{ + Username: username, + Password: password, + }) + ctx = security.WithToken(ctx, csrf) + ctx = handlers.ContextWithCapabilities(ctx, handlers.NewCapabilities(features)) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func csrfMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := security.TokenFromContext(r.Context()) + token = security.EnsureToken(w, r, token) + r = r.WithContext(security.WithToken(r.Context(), token)) + + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace: + next.ServeHTTP(w, r) + return + } + + if !sameOrigin(r) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if !validCSRF(r, token) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) +} + +func sameOrigin(r *http.Request) bool { + host := r.Host + if xf := r.Header.Get("X-Forwarded-Host"); xf != "" { + parts := strings.Split(xf, ",") + host = strings.TrimSpace(parts[0]) + } + if host == "" { + return false + } + + origin := r.Header.Get("Origin") + if origin != "" { + u, err := url.Parse(origin) + if err != nil { + return false + } + return strings.EqualFold(u.Host, host) + } + + ref := r.Header.Get("Referer") + if ref != "" { + u, err := url.Parse(ref) + if err != nil { + return false + } + return strings.EqualFold(u.Host, host) + } + + return true +} + +func validCSRF(r *http.Request, token string) bool { + if token == "" { + return false + } + if hdr := r.Header.Get("X-CSRF-Token"); hdr != "" { + return subtleConstantTimeEquals(hdr, token) + } + if err := r.ParseForm(); err != nil { + return false + } + return subtleConstantTimeEquals(r.FormValue("csrf"), token) +} + +func subtleConstantTimeEquals(a, b string) bool { + if len(a) != len(b) { + return false + } + var diff byte + for i := 0; i < len(a); i++ { + diff |= a[i] ^ b[i] + } + return diff == 0 +} + +func securityHeadersMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'") + if security.IsSecureRequest(r) { + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } + next.ServeHTTP(w, r) + }) +} + +func isPublicPath(path string) bool { + return path == "/login" || strings.HasPrefix(path, "/assets/") +} + +func isPollingPath(path string) bool { + return path == "/device-status" || strings.HasSuffix(path, "/counters") +} + +func deny(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("HX-Request") == "true" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + http.Redirect(w, r, "/login", http.StatusSeeOther) +} diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go new file mode 100644 index 00000000..63087d39 --- /dev/null +++ b/src/webui/internal/server/server.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT + +package server + +import ( + "html/template" + "io/fs" + "net/http" + + "github.com/kernelkit/webui/internal/auth" + "github.com/kernelkit/webui/internal/handlers" + "github.com/kernelkit/webui/internal/restconf" +) + +// New creates a fully wired http.Handler with all routes and middleware. +func New( + store *auth.SessionStore, + rc *restconf.Client, + templateFS fs.FS, + staticFS fs.FS, +) (http.Handler, error) { + // Parse templates per page so each can define its own "content" block + // without collisions. + loginTmpl, err := template.ParseFS(templateFS, "pages/login.html") + if err != nil { + return nil, err + } + dashTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/dashboard.html") + if err != nil { + return nil, err + } + fwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/firewall.html") + if err != nil { + return nil, err + } + ksTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/keystore.html") + if err != nil { + return nil, err + } + ifTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/interfaces.html") + if err != nil { + return nil, err + } + ifDetailTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/iface-detail.html", "fragments/iface-counters.html") + if err != nil { + return nil, err + } + ifCountersTmpl, err := template.ParseFS(templateFS, "fragments/iface-counters.html") + if err != nil { + return nil, err + } + fwrTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/firmware.html") + if err != nil { + return nil, err + } + routingTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/routing.html") + if err != nil { + return nil, err + } + wifiTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/wifi.html") + if err != nil { + return nil, err + } + vpnTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/vpn.html") + if err != nil { + return nil, err + } + dhcpTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/dhcp.html") + if err != nil { + return nil, err + } + ntpTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/ntp.html") + if err != nil { + return nil, err + } + lldpTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/lldp.html") + if err != nil { + return nil, err + } + containersTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/containers.html") + if err != nil { + return nil, err + } + + login := &auth.LoginHandler{ + Store: store, + RC: rc, + Template: loginTmpl, + } + + dash := &handlers.DashboardHandler{ + Template: dashTmpl, + RC: rc, + } + + fw := &handlers.FirewallHandler{ + Template: fwTmpl, + RC: rc, + } + + ks := &handlers.KeystoreHandler{ + Template: ksTmpl, + RC: rc, + } + + iface := &handlers.InterfacesHandler{ + Template: ifTmpl, + DetailTemplate: ifDetailTmpl, + CountersTemplate: ifCountersTmpl, + RC: rc, + } + + sys := &handlers.SystemHandler{ + RC: rc, + Template: fwrTmpl, + } + + routing := &handlers.RoutingHandler{Template: routingTmpl, RC: rc} + wifi := &handlers.WiFiHandler{Template: wifiTmpl, RC: rc} + vpn := &handlers.VPNHandler{Template: vpnTmpl, RC: rc} + dhcp := &handlers.DHCPHandler{Template: dhcpTmpl, RC: rc} + ntp := &handlers.NTPHandler{Template: ntpTmpl, RC: rc} + lldp := &handlers.LLDPHandler{Template: lldpTmpl, RC: rc} + containers := &handlers.ContainersHandler{Template: containersTmpl, RC: rc} + + mux := http.NewServeMux() + + // Auth routes (public). + mux.HandleFunc("GET /login", login.ShowLogin) + mux.HandleFunc("POST /login", login.DoLogin) + mux.HandleFunc("POST /logout", login.DoLogout) + + // Static assets (public). + staticServer := http.FileServerFS(staticFS) + mux.Handle("GET /assets/", http.StripPrefix("/assets/", staticServer)) + + // Authenticated routes. + mux.HandleFunc("GET /{$}", dash.Index) + mux.HandleFunc("GET /interfaces", iface.Overview) + mux.HandleFunc("GET /interfaces/{name}", iface.Detail) + mux.HandleFunc("GET /interfaces/{name}/counters", iface.Counters) + mux.HandleFunc("GET /firewall", fw.Overview) + mux.HandleFunc("GET /keystore", ks.Overview) + mux.HandleFunc("GET /firmware", sys.Firmware) + mux.HandleFunc("POST /firmware/install", sys.FirmwareInstall) + mux.HandleFunc("POST /reboot", sys.Reboot) + mux.HandleFunc("GET /device-status", sys.DeviceStatus) + mux.HandleFunc("GET /config", sys.DownloadConfig) + mux.HandleFunc("GET /routing", routing.Overview) + mux.HandleFunc("GET /wifi", wifi.Overview) + mux.HandleFunc("GET /vpn", vpn.Overview) + mux.HandleFunc("GET /dhcp", dhcp.Overview) + mux.HandleFunc("GET /ntp", ntp.Overview) + mux.HandleFunc("GET /lldp", lldp.Overview) + mux.HandleFunc("GET /containers", containers.Overview) + + handler := authMiddleware(store, mux) + handler = csrfMiddleware(handler) + handler = securityHeadersMiddleware(handler) + return handler, nil +} diff --git a/src/webui/internal/testutil/helpers.go b/src/webui/internal/testutil/helpers.go new file mode 100644 index 00000000..79f9d407 --- /dev/null +++ b/src/webui/internal/testutil/helpers.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT + +package testutil + +import ( + "context" + "encoding/json" + "sync" +) + +type mockEntry struct { + body any + err error +} + +type MockFetcher struct { + mu sync.Mutex + responses map[string]mockEntry + errors map[string]error +} + +func NewMockFetcher() *MockFetcher { + return &MockFetcher{ + responses: make(map[string]mockEntry), + errors: make(map[string]error), + } +} + +func (m *MockFetcher) SetResponse(path string, body any) { + m.mu.Lock() + defer m.mu.Unlock() + m.responses[path] = mockEntry{body: body} +} + +func (m *MockFetcher) SetError(path string, err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.errors[path] = err +} + +func (m *MockFetcher) Get(_ context.Context, path string, target any) error { + m.mu.Lock() + defer m.mu.Unlock() + + if err, ok := m.errors[path]; ok { + return err + } + + entry, ok := m.responses[path] + if !ok { + return nil + } + + raw, err := json.Marshal(entry.body) + if err != nil { + return err + } + return json.Unmarshal(raw, target) +} + +func (m *MockFetcher) GetRaw(_ context.Context, path string) ([]byte, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if err, ok := m.errors[path]; ok { + return nil, err + } + + entry, ok := m.responses[path] + if !ok { + return []byte("{}"), nil + } + + return json.Marshal(entry.body) +} + +func (m *MockFetcher) Post(_ context.Context, path string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if err, ok := m.errors[path]; ok { + return err + } + return nil +} + +func (m *MockFetcher) PostJSON(_ context.Context, path string, _ any) error { + m.mu.Lock() + defer m.mu.Unlock() + + if err, ok := m.errors[path]; ok { + return err + } + return nil +} diff --git a/src/webui/main.go b/src/webui/main.go new file mode 100644 index 00000000..65fc5246 --- /dev/null +++ b/src/webui/main.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "embed" + "flag" + "io/fs" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/kernelkit/webui/internal/auth" + "github.com/kernelkit/webui/internal/restconf" + "github.com/kernelkit/webui/internal/server" +) + +//go:embed templates/* +var templateFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +func main() { + defaultRC := "http://localhost:8080/restconf" + if env := os.Getenv("RESTCONF_URL"); env != "" { + defaultRC = env + } + + listen := flag.String("listen", ":10000", "address to listen on") + restconfURL := flag.String("restconf", defaultRC, "RESTCONF base URL") + sessionKey := flag.String("session-key", "/var/lib/misc/webui-session.key", "path to persistent session key file") + insecureTLS := flag.Bool("insecure-tls", envBool("INSECURE_TLS"), "disable TLS certificate verification") + flag.Parse() + + store, err := auth.NewSessionStore(*sessionKey) + if err != nil { + log.Fatalf("session store: %v", err) + } + + rc := restconf.NewClient(*restconfURL, *insecureTLS) + + tmplFS, err := fs.Sub(templateFS, "templates") + if err != nil { + log.Fatalf("template fs: %v", err) + } + + stFS, err := fs.Sub(staticFS, "static") + if err != nil { + log.Fatalf("static fs: %v", err) + } + + handler, err := server.New(store, rc, tmplFS, stFS) + if err != nil { + log.Fatalf("server setup: %v", err) + } + + log.Printf("listening on %s (restconf %s)", *listen, *restconfURL) + srv := &http.Server{ + Addr: *listen, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + if err := srv.ListenAndServe(); err != nil { + log.Fatalf("listen: %v", err) + } +} + +func envBool(key string) bool { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return false + } + switch strings.ToLower(v) { + case "1", "true", "yes", "y", "on": + return true + default: + return false + } +} diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css new file mode 100644 index 00000000..2a6c6980 --- /dev/null +++ b/src/webui/static/css/style.css @@ -0,0 +1,1100 @@ +/* ========================================================================== + Design System: Primitives & Tokens + ========================================================================== */ + +:root { + /* --- Primitives: Slate (Neutral) --- */ + --slate-50: #f8fafc; + --slate-100: #f1f5f9; + --slate-200: #e2e8f0; + --slate-300: #cbd5e1; + --slate-400: #94a3b8; + --slate-500: #64748b; + --slate-600: #475569; + --slate-700: #334155; + --slate-800: #1e293b; + --slate-900: #0f172a; + --slate-950: #020617; + + /* --- Primitives: Colors --- */ + --blue-500: #3b82f6; + --blue-600: #2563eb; + --blue-700: #1d4ed8; + --green-500: #22c55e; + --green-600: #16a34a; + --amber-500: #f59e0b; + --amber-600: #d97706; + --red-500: #ef4444; + --red-600: #dc2626; + + /* --- Semantic Tokens: Light Mode (Default) --- */ + --bg: var(--slate-50); + --surface: #ffffff; + --fg: var(--slate-900); + --fg-muted: var(--slate-500); + --border: var(--slate-200); + --border-subtle: var(--slate-100); + + --primary: var(--blue-500); + --primary-hover: var(--blue-600); + + --success: var(--green-500); + --warning: var(--amber-500); + --danger: var(--red-500); + + /* Sidebar (Always Dark) */ + --sidebar-bg: var(--slate-800); + --sidebar-fg: var(--slate-200); + --sidebar-hover: rgba(255, 255, 255, 0.1); + --sidebar-border: rgba(255, 255, 255, 0.1); + --sidebar-width: 240px; + + /* Layout */ + --radius: 8px; + --radius-sm: 4px; + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; + + /* Legacy variable mapping */ + --card-bg: var(--surface); +} + +/* --- Dark Mode Overrides --- */ +@media (prefers-color-scheme: dark) { + :root { + --bg: var(--slate-900); + --surface: var(--slate-800); + --fg: var(--slate-100); + --fg-muted: var(--slate-400); + --border: var(--slate-700); + --border-subtle: var(--slate-800); + } +} + +/* Manual Dark Mode Toggle */ +.dark { + --bg: var(--slate-900); + --surface: var(--slate-800); + --fg: var(--slate-100); + --fg-muted: var(--slate-400); + --border: var(--slate-700); + --border-subtle: var(--slate-800); + --zebra-stripe: rgba(255,255,255,0.03); +} + +/* Force Light Mode (override system dark preference) */ +.light { + --bg: var(--slate-50); + --surface: #ffffff; + --fg: var(--slate-900); + --fg-muted: var(--slate-500); + --border: var(--slate-200); + --border-subtle: var(--slate-100); +} +.light .alert-error { background: #fef2f2; color: #991b1b; border-color: #fecaca; } +.light .alert-info { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; } +.light .health-bar-track { background-color: var(--slate-200); } +.light .zone-badge { + background-color: hsl(var(--zone-hue), 80%, 90%); + color: hsl(var(--zone-hue), 80%, 30%); + border-color: hsl(var(--zone-hue), 60%, 80%); +} +.light .matrix-allow { background: #dcfce7; color: #166534; } +.light .matrix-deny { background: #fef2f2; color: #991b1b; } +.light .matrix-self { background: var(--slate-100); color: var(--slate-400); } +.light .badge-accept { background: #dcfce7; color: #166534; } +.light .badge-reject { background: #fef2f2; color: #991b1b; } +.light .badge-drop { background: var(--slate-100); color: var(--slate-600); } +.light .badge-continue { background: #dbeafe; color: #1e40af; } +.light .key-full { background: var(--slate-100); } + +/* ========================================================================== + Reset & Base + ========================================================================== */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.5; + color: var(--fg); + background: var(--bg); + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--primary); + text-decoration: none; + transition: color 0.15s ease; +} + +a:hover { + text-decoration: underline; +} + +/* ========================================================================== + Layout + ========================================================================== */ + +.layout { + display: flex; + min-height: 100vh; +} + +#sidebar { + width: var(--sidebar-width); + background: var(--sidebar-bg); + color: var(--sidebar-fg); + display: flex; + flex-direction: column; + flex-shrink: 0; + border-right: 1px solid var(--sidebar-border); +} + +#content { + flex: 1; + padding: 2rem; + overflow-y: auto; + height: 100vh; +} + +/* ========================================================================== + Sidebar Components + ========================================================================== */ + +.sidebar-header { + padding: 1.5rem 1.25rem; + border-bottom: 1px solid var(--sidebar-border); +} + +.sidebar-logo { + max-width: 100%; + height: auto; + display: block; +} + +.sidebar-nav { + list-style: none; + flex: 1; + padding: 1rem 0; +} + +.nav-section-header { + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--sidebar-fg); + opacity: 0.5; + padding: 1rem 1rem 0.25rem; + pointer-events: none; +} + +.nav-icon { + display: inline-block; + width: 1rem; + height: 1rem; + background-color: currentColor; + -webkit-mask-image: var(--icon); + mask-image: var(--icon); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + flex-shrink: 0; +} + +.nav-link { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.5rem 1rem; + color: var(--sidebar-fg); + text-decoration: none; + font-size: 0.875rem; + border-radius: 0.375rem; + margin: 0 0.5rem; + transition: background-color 0.15s; + font-weight: 500; +} + +.nav-link:hover { + background-color: var(--sidebar-hover); + color: #fff; + text-decoration: none; +} + +.nav-link.active { + background-color: var(--primary); + color: #fff; + text-decoration: none; +} + +.sidebar-footer { + padding: 1.25rem; + border-top: 1px solid var(--sidebar-border); +} + +/* ========================================================================== + Buttons + ========================================================================== */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 1rem; + border: 1px solid transparent; + border-radius: var(--radius); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + text-decoration: none; + transition: all 0.15s ease; + line-height: 1.25; +} + +.btn-primary { + background: var(--primary); + color: #fff; +} + +.btn-primary:hover { + background: var(--primary-hover); + text-decoration: none; +} + +.btn-block { + display: flex; + width: 100%; +} + +/* Sidebar Actions */ +.btn-sidebar-action, .btn-logout { + display: flex; + width: 100%; + align-items: center; + justify-content: center; + background: transparent; + color: var(--sidebar-fg); + border: 1px solid rgba(255,255,255,0.2); + padding: 0.5rem 0.75rem; + border-radius: var(--radius); + cursor: pointer; + font-size: 0.85rem; + text-decoration: none; + margin-bottom: 0.5rem; + transition: all 0.15s ease; +} + +.btn-sidebar-action:hover, .btn-logout:hover { + background: var(--sidebar-hover); + border-color: rgba(255,255,255,0.3); + text-decoration: none; +} + +.btn-danger-hover:hover { + border-color: var(--danger); + color: #fca5a5; + background: rgba(220, 38, 38, 0.1); +} + +/* ========================================================================== + Cards & Content Containers + ========================================================================== */ + +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.info-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); + overflow: hidden; + margin-bottom: 1.5rem; + display: flex; + flex-direction: column; +} + +/* Legacy support: info-card often contains direct h2 */ +.info-card h2 { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; + color: var(--fg-muted); + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border); + margin: 0; + background: var(--bg); +} + +.card-header { + padding: 0.75rem 1.25rem; + background: var(--bg); + border-bottom: 1px solid var(--border); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; + color: var(--fg-muted); +} + +.card-body { + padding: 1.25rem; +} + +/* ========================================================================== + Tables + ========================================================================== */ + +.info-table, .disk-table, .counters-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.info-table th, .info-table td, +.disk-table th, .disk-table td, +.counters-table th, .counters-table td { + padding: 0.75rem 1.25rem; + border-bottom: 1px solid var(--border); +} + +.info-table tr:last-child th, .info-table tr:last-child td, +.disk-table tbody tr:last-child td, +.counters-table tbody tr:last-child td { + border-bottom: none; +} + +.info-table tr:nth-child(even), +.disk-table tbody tr:nth-child(even), +.counters-table tbody tr:nth-child(even) { + background-color: var(--zebra-stripe, rgba(0,0,0,0.025)); +} + +.info-table th { + text-align: left; + font-weight: 500; + color: var(--fg-muted); + width: 40%; + background: transparent; +} + +.disk-table thead th, .counters-table thead th { + background: var(--bg); + font-weight: 600; + color: var(--fg-muted); + text-align: left; + font-size: 0.8rem; + text-transform: uppercase; +} + +.counters-table td { + font-family: var(--font-mono); +} + +/* New Data Table Styles */ +.data-table-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + white-space: nowrap; +} + +.data-table th, .data-table td { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + text-align: left; +} + +.data-table th { + background: var(--bg); + color: var(--fg-muted); + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; +} + +.data-table tbody tr:nth-child(even) { + background-color: var(--zebra-stripe, rgba(0,0,0,0.025)); +} + +.data-table tbody tr:hover { + background-color: rgba(0,0,0,0.05); +} + +/* ========================================================================== + Forms & Inputs + ========================================================================== */ + +.form-group { + margin-bottom: 1.25rem; +} + +.form-group label { + display: block; + font-size: 0.85rem; + font-weight: 600; + margin-bottom: 0.4rem; + color: var(--fg); +} + +.form-group input { + display: block; + width: 100%; + padding: 0.6rem 0.8rem; + background: var(--surface); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.9rem; + transition: all 0.15s ease; +} + +.form-group input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +/* ========================================================================== + Authentication + ========================================================================== */ + +.login-wrapper { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: var(--bg); + padding: 1rem; +} + +.login-card { + background: var(--surface); + padding: 2.5rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + width: 100%; + max-width: 400px; + border: 1px solid var(--border); +} + +.login-logo { + display: block; + max-width: 180px; + height: auto; + margin: 0 auto 2rem; +} + +/* ========================================================================== + Alerts & Banners + ========================================================================== */ + +.conn-banner { + background: var(--danger); + color: #fff; + text-align: center; + padding: 0.5rem; + font-size: 0.9rem; + font-weight: 600; + position: sticky; + top: 0; + z-index: 50; +} + +.alert { + padding: 1rem; + border-radius: var(--radius); + margin-bottom: 1.5rem; + font-size: 0.9rem; + border: 1px solid transparent; +} + +.alert-error { + background: #fef2f2; + color: var(--red-600); + border-color: #fecaca; +} + +.alert-info { + background: #eff6ff; + color: var(--blue-700); + border-color: #bfdbfe; +} + +/* Dark mode adjustments for alerts */ +@media (prefers-color-scheme: dark) { + .alert-error { + background: rgba(239, 68, 68, 0.1); + color: #fca5a5; + border-color: rgba(239, 68, 68, 0.2); + } + .alert-info { + background: rgba(59, 130, 246, 0.1); + color: #93c5fd; + border-color: rgba(59, 130, 246, 0.2); + } +} +.dark .alert-error { + background: rgba(239, 68, 68, 0.1); + color: #fca5a5; + border-color: rgba(239, 68, 68, 0.2); +} +.dark .alert-info { + background: rgba(59, 130, 246, 0.1); + color: #93c5fd; + border-color: rgba(59, 130, 246, 0.2); +} + +/* ========================================================================== + Status Indicators + ========================================================================== */ + +/* Legacy Status Dots */ +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 0.5rem; + vertical-align: middle; +} + +.status-up { background: var(--success); } +.status-down { background: var(--danger); } + +/* New Interface Status Indicators */ +.iface-status { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 0.5rem; +} + +.iface-up { + background-color: var(--success); + box-shadow: 0 0 0 rgba(34, 197, 94, 0.4); + animation: pulse-green 2s infinite; +} + +.iface-down { + background-color: var(--danger); +} + +.iface-lower-down { + background-color: var(--warning); +} + +@keyframes pulse-green { + 0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7); } + 70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); } + 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } +} + + +/* ========================================================================== + Components: Health Bar + ========================================================================== */ + +.health-bar-track { + width: 100%; + height: 6px; + background-color: var(--slate-200); + border-radius: 99px; + overflow: hidden; +} + +.health-bar-fill { + height: 100%; + background-color: var(--success); + border-radius: 99px; + transition: width 0.5s ease; +} + +.health-bar-fill.is-warn { background-color: var(--warning); } +.health-bar-fill.is-crit { background-color: var(--danger); } + +/* Dark mode track */ +@media (prefers-color-scheme: dark) { + .health-bar-track { background-color: var(--slate-700); } +} +.dark .health-bar-track { background-color: var(--slate-700); } + +/* ========================================================================== + Components: Zone Badge + ========================================================================== */ + +.zone-badge { + /* Requires --zone-hue to be set inline or via utility */ + --zone-hue: 200; /* Default */ + display: inline-flex; + align-items: center; + padding: 0.25rem 0.6rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 600; + background-color: hsl(var(--zone-hue), 80%, 90%); + color: hsl(var(--zone-hue), 80%, 30%); + border: 1px solid hsl(var(--zone-hue), 60%, 80%); +} + +@media (prefers-color-scheme: dark) { + .zone-badge { + background-color: hsl(var(--zone-hue), 60%, 20%); + color: hsl(var(--zone-hue), 80%, 85%); + border-color: hsl(var(--zone-hue), 60%, 30%); + } +} +.dark .zone-badge { + background-color: hsl(var(--zone-hue), 60%, 20%); + color: hsl(var(--zone-hue), 80%, 85%); + border-color: hsl(var(--zone-hue), 60%, 30%); +} + +/* ========================================================================== + Utilities + ========================================================================== */ + +.num { + font-variant-numeric: tabular-nums; + font-family: var(--font-mono); +} + +/* ========================================================================== + Domain Specific: Firewall, Keystore, Firmware + ========================================================================== */ + +/* Zone Matrix */ +.matrix-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.zone-matrix { + border-collapse: collapse; + font-size: 0.9rem; + width: 100%; +} + +.zone-matrix th, .zone-matrix td { + padding: 0.75rem; + text-align: center; + border: 1px solid var(--border); + min-width: 90px; +} + +.zone-matrix thead th { + font-weight: 600; + color: var(--fg-muted); + background: var(--bg); +} + +.zone-matrix tbody th { + font-weight: 600; + text-align: right; + background: var(--bg); + color: var(--fg-muted); +} + +.matrix-corner { + color: var(--fg-muted); +} + +.matrix-allow { + background: #dcfce7; + color: #166534; + font-weight: 700; +} +.matrix-deny { + background: #fef2f2; + color: #991b1b; + font-weight: 700; +} +.matrix-self { + background: var(--slate-100); + color: var(--slate-400); +} + +@media (prefers-color-scheme: dark) { + .matrix-allow { background: rgba(22, 163, 74, 0.2); color: #86efac; } + .matrix-deny { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } + .matrix-self { background: var(--slate-800); color: var(--slate-600); } +} +.dark .matrix-allow { background: rgba(22, 163, 74, 0.2); color: #86efac; } +.dark .matrix-deny { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } +.dark .matrix-self { background: var(--slate-800); color: var(--slate-600); } + +.matrix-legend { + display: flex; + gap: 1.5rem; + font-size: 0.85rem; + color: var(--fg-muted); + margin-top: 1rem; +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.legend-swatch { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--border); +} + +/* Badges */ +.badge { + display: inline-block; + padding: 0.25rem 0.6rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 600; + text-transform: capitalize; +} + +.badge-accept { background: #dcfce7; color: #166534; } +.badge-reject { background: #fef2f2; color: #991b1b; } +.badge-drop { background: var(--slate-100); color: var(--slate-600); } +.badge-continue { background: #dbeafe; color: #1e40af; } + +@media (prefers-color-scheme: dark) { + .badge-accept { background: rgba(22, 163, 74, 0.2); color: #86efac; } + .badge-reject { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } + .badge-drop { background: var(--slate-800); color: var(--slate-400); } + .badge-continue { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } +} +.dark .badge-accept { background: rgba(22, 163, 74, 0.2); color: #86efac; } +.dark .badge-reject { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } +.dark .badge-drop { background: var(--slate-800); color: var(--slate-400); } +.dark .badge-continue { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } + +/* Interfaces */ +.iface-name { white-space: nowrap; font-weight: 500; } +.iface-name a { color: var(--primary); text-decoration: none; } +.iface-name a:hover { text-decoration: underline; } + +.tree-pipe { color: var(--slate-300); font-family: var(--font-mono); margin-right: 0.5rem; } +.addr-origin { color: var(--fg-muted); font-size: 0.75rem; margin-left: 0.5rem; } + +.wg-peer { + padding: 1rem 0; + border-bottom: 1px solid var(--border); +} +.wg-peer:last-child { border-bottom: none; } + +.eth-stats { + display: grid; + grid-template-columns: 1fr 1fr; + font-size: 0.85rem; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} +.eth-stats-row { + display: flex; + justify-content: space-between; + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.eth-stats dt { color: var(--fg-muted); font-family: var(--font-mono); } +.eth-stats dd { text-align: right; font-family: var(--font-mono); font-weight: 600; } + +.iface-detail-header { margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 1rem; } +.iface-detail-header h2 { font-size: 1.5rem; font-weight: 600; color: var(--fg); } +.back-link { display: inline-flex; align-items: center; margin-bottom: 0.5rem; font-size: 0.9rem; } + +.info-card h3 { + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; + color: var(--fg-muted); + margin: 1.5rem 0 0.75rem; + padding-left: 0.5rem; + border-left: 3px solid var(--primary); +} + +/* Firmware & Reboot */ +.firmware-form { display: flex; gap: 1rem; align-items: flex-end; } +.firmware-form .form-group { flex: 1; margin-bottom: 0; } + +.progress-bar-wrap { + background: var(--slate-200); + border-radius: var(--radius); + height: 1.25rem; + overflow: hidden; + margin-top: 0.5rem; +} +.progress-bar { + background: var(--primary); + height: 100%; + width: 0%; + transition: width 0.3s ease; +} +.progress-text { font-size: 0.85rem; color: var(--fg-muted); margin-top: 0.5rem; text-align: center; } + +.reboot-overlay { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; +} +.reboot-spinner { + width: 48px; + height: 48px; + border: 4px solid var(--slate-200); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1.5rem; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.reboot-message { font-size: 1.25rem; font-weight: 600; color: var(--fg); } +.reboot-status { font-size: 1rem; color: var(--fg-muted); margin-top: 0.5rem; } +.reboot-status.is-error { color: var(--danger); } + +/* Keystore */ +.empty-message { color: var(--fg-muted); font-style: italic; padding: 1rem; text-align: center; } +.key-full { font-family: var(--font-mono); font-size: 0.8rem; word-break: break-all; color: var(--fg); background: var(--slate-100); padding: 0.25rem; border-radius: 4px; } +@media (prefers-color-scheme: dark) { .key-full { background: var(--slate-800); } } +.dark .key-full { background: var(--slate-800); } + +/* Survey */ +.survey-chart { max-width: 100%; height: auto; display: block; } +.survey-hint { font-size: 0.8rem; color: var(--fg-muted); margin-bottom: 1rem; } + +/* WiFi */ +.wifi-radio-header { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.wifi-caps { + display: flex; + gap: 0.375rem; + align-items: center; +} + +.wifi-survey-body { + padding: 1rem; + display: flex; + align-items: center; + justify-content: center; +} + +.wifi-survey-body .survey-chart { + max-height: 220px; + width: auto; +} + +.wifi-ssid { + font-weight: 400; + color: var(--fg-muted); + font-size: 0.85rem; + margin-left: 0.5rem; + font-family: var(--font-mono); +} + +.wifi-band-list { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.wifi-band-item { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.wifi-band-caps { + display: inline-flex; + gap: 0.25rem; +} + +/* Signal Strength */ +.signal-excellent { color: var(--success); font-weight: 600; } +.signal-good { color: var(--green-600); font-weight: 600; } +.signal-ok { color: var(--warning); font-weight: 600; } +.signal-poor { color: var(--danger); font-weight: 600; } + +/* ========================================================================== + Topbar & Theme Toggle + ========================================================================== */ + +.topbar { + display: none; + align-items: center; + justify-content: flex-end; + padding: 0.5rem 1rem; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.theme-toggle { + background: none; + border: none; + cursor: pointer; + color: var(--sidebar-fg); + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0; +} + +.theme-toggle:hover { + color: #fff; +} + +.hamburger-btn { + background: none; + border: 1px solid var(--border); + border-radius: 0.375rem; + padding: 0.375rem; + cursor: pointer; + color: var(--fg-muted); + display: none; /* Hidden by default; shown via media query */ + align-items: center; +} +.hamburger-btn:hover { + color: var(--fg); + border-color: var(--fg-muted); +} + +/* ========================================================================== + Responsive Layout + ========================================================================== */ + +/* Mobile (≤768px): Sidebar hidden, hamburger visible */ +@media (max-width: 768px) { + #sidebar { + position: fixed; + top: 0; + left: 0; + height: 100%; + z-index: 200; + transform: translateX(-100%); + transition: transform 0.25s ease; + width: var(--sidebar-width); + } + + body.sidebar-open #sidebar { + transform: translateX(0); + } + + body.sidebar-open::after { + content: ''; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.4); + z-index: 100; + } + + #content { + height: auto; + min-height: 100vh; + } + + .hamburger-btn { + display: flex; + } + + .topbar { + display: flex; + justify-content: space-between; + } +} + +/* Tablet (769px–1024px): Icon-only sidebar */ +@media (min-width: 769px) and (max-width: 1024px) { + :root { + --sidebar-width: 64px; + } + + .nav-link span:not(.nav-icon) { + display: none; + } + + .nav-section-header { + display: none; + } + + .nav-link { + justify-content: center; + padding: 0.6rem; + } + + .sidebar-footer .btn-sidebar-action span:not(.nav-icon) { + display: none; + } + + .sidebar-footer .btn-logout span:not(.nav-icon) { + display: none; + } + + .theme-toggle span { + display: none; + } + + .theme-toggle { + justify-content: center; + } + + .sidebar-header { + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + } + + .sidebar-logo { + max-width: 32px; + } +} + +/* Desktop: hamburger hidden */ +@media (min-width: 769px) { + .hamburger-btn { + display: none; + } +} + +/* Column priority hiding for data tables */ +@media (max-width: 600px) { + .col-priority-2 { display: none; } +} +@media (max-width: 480px) { + .col-priority-3 { display: none; } +} diff --git a/src/webui/static/img/icons/containers.svg b/src/webui/static/img/icons/containers.svg new file mode 100644 index 00000000..a6389999 --- /dev/null +++ b/src/webui/static/img/icons/containers.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/dashboard.svg b/src/webui/static/img/icons/dashboard.svg new file mode 100644 index 00000000..0ec39729 --- /dev/null +++ b/src/webui/static/img/icons/dashboard.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/dhcp.svg b/src/webui/static/img/icons/dhcp.svg new file mode 100644 index 00000000..30823ecf --- /dev/null +++ b/src/webui/static/img/icons/dhcp.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/firewall.svg b/src/webui/static/img/icons/firewall.svg new file mode 100644 index 00000000..e4058c25 --- /dev/null +++ b/src/webui/static/img/icons/firewall.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/firmware.svg b/src/webui/static/img/icons/firmware.svg new file mode 100644 index 00000000..a7669492 --- /dev/null +++ b/src/webui/static/img/icons/firmware.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/iface-bridge.svg b/src/webui/static/img/icons/iface-bridge.svg new file mode 100644 index 00000000..4dde450f --- /dev/null +++ b/src/webui/static/img/icons/iface-bridge.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/iface-container.svg b/src/webui/static/img/icons/iface-container.svg new file mode 100644 index 00000000..a6389999 --- /dev/null +++ b/src/webui/static/img/icons/iface-container.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/iface-ethernet.svg b/src/webui/static/img/icons/iface-ethernet.svg new file mode 100644 index 00000000..6c0ee150 --- /dev/null +++ b/src/webui/static/img/icons/iface-ethernet.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/iface-gre.svg b/src/webui/static/img/icons/iface-gre.svg new file mode 100644 index 00000000..0c966494 --- /dev/null +++ b/src/webui/static/img/icons/iface-gre.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/iface-lag.svg b/src/webui/static/img/icons/iface-lag.svg new file mode 100644 index 00000000..64d060d9 --- /dev/null +++ b/src/webui/static/img/icons/iface-lag.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/iface-veth.svg b/src/webui/static/img/icons/iface-veth.svg new file mode 100644 index 00000000..62c3728f --- /dev/null +++ b/src/webui/static/img/icons/iface-veth.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/iface-vlan.svg b/src/webui/static/img/icons/iface-vlan.svg new file mode 100644 index 00000000..379524d9 --- /dev/null +++ b/src/webui/static/img/icons/iface-vlan.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/iface-vxlan.svg b/src/webui/static/img/icons/iface-vxlan.svg new file mode 100644 index 00000000..2ce493b9 --- /dev/null +++ b/src/webui/static/img/icons/iface-vxlan.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/iface-wifi.svg b/src/webui/static/img/icons/iface-wifi.svg new file mode 100644 index 00000000..a344e5a3 --- /dev/null +++ b/src/webui/static/img/icons/iface-wifi.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/iface-wireguard.svg b/src/webui/static/img/icons/iface-wireguard.svg new file mode 100644 index 00000000..b321cd2d --- /dev/null +++ b/src/webui/static/img/icons/iface-wireguard.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/interfaces.svg b/src/webui/static/img/icons/interfaces.svg new file mode 100644 index 00000000..6c0ee150 --- /dev/null +++ b/src/webui/static/img/icons/interfaces.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/keystore.svg b/src/webui/static/img/icons/keystore.svg new file mode 100644 index 00000000..1a8c6f99 --- /dev/null +++ b/src/webui/static/img/icons/keystore.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/lldp.svg b/src/webui/static/img/icons/lldp.svg new file mode 100644 index 00000000..b6e2ac80 --- /dev/null +++ b/src/webui/static/img/icons/lldp.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/ntp.svg b/src/webui/static/img/icons/ntp.svg new file mode 100644 index 00000000..b6461c17 --- /dev/null +++ b/src/webui/static/img/icons/ntp.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/routing.svg b/src/webui/static/img/icons/routing.svg new file mode 100644 index 00000000..f0ae57aa --- /dev/null +++ b/src/webui/static/img/icons/routing.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/status-down.svg b/src/webui/static/img/icons/status-down.svg new file mode 100644 index 00000000..aeb3a283 --- /dev/null +++ b/src/webui/static/img/icons/status-down.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/status-up.svg b/src/webui/static/img/icons/status-up.svg new file mode 100644 index 00000000..6a6a46af --- /dev/null +++ b/src/webui/static/img/icons/status-up.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/status-warning.svg b/src/webui/static/img/icons/status-warning.svg new file mode 100644 index 00000000..421cf3db --- /dev/null +++ b/src/webui/static/img/icons/status-warning.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/wifi.svg b/src/webui/static/img/icons/wifi.svg new file mode 100644 index 00000000..a344e5a3 --- /dev/null +++ b/src/webui/static/img/icons/wifi.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/webui/static/img/icons/wireguard.svg b/src/webui/static/img/icons/wireguard.svg new file mode 100644 index 00000000..b321cd2d --- /dev/null +++ b/src/webui/static/img/icons/wireguard.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/webui/static/img/jack.png b/src/webui/static/img/jack.png new file mode 100644 index 0000000000000000000000000000000000000000..b2026c16de00c184e566cba76db0c3948371a2a2 GIT binary patch literal 1759 zcmbtV`#aMM7@ymyhNz=2j>{y3T*hj-fNhy~y zwA^Z4Xk;BXC#`bWX^h?^9P)#^FGh#{k+fnKF|Bh`@Wwy!_&h}aihjY5D27r zz#Vy1!VC%06~Geja<;BqLK_I~{!t*1R_aekeb$7{Ns{W(u71&8=fa{%!NgDyi9|BP zMxKod2_}S^og;={|752D0)cS{kWRYgW8YMYdw}C-_&~Xn9U8Li) z;Af2bCu{7zrGdPGfunHrmJlJPFRs4PvW*@gt$p6jMbjL;+*vy?#Xx=WAUK;*aRwXghF zv~yTI9-np4%R$QTlL&)UzuJpVY}ps3ANC@@s?Y>o!nJHG*6j+ljV7Q`P51ps3DSDD z78q4%M5CpHgHC@{RXu#uk%QY;+$`Ym0%A)jyT8955i^-T@1jy65Yp~gA`n-Jwlp<; zX%_<(0G7eZIUq|#=}B57LGu(K*N{Q%|6cMt={oPmjo7;8APm9bAl?Pu5s{JmzjYCP z!!&-Slpf~r$?J( zYP+LXdRh=GoV}=hrNvfW(}pEO4cI&rt9TWfn$_*ww+VHtlf~&$K}(DJ&CM+%w1gw~!aq$D&;wE>db>ws% z00x5@cDW==p89RrE~d%$RX1L$c$;UKD16D7YFA!ctJL29WjvT!-2j2Z;r^uxl{I%= zvDn$xk%NC8@;v2R#?NTYg!UL28#@c+YZPRqqsZD2Fqb=)6lAelmYMQY*XG^H3-gY; z7YEjAj7&|tzoSmJ*Q@mmHHVt`cvnAb6rX)_XZ+s1UIw$hpnS2qgSx-i?hn;O-__Dm z^2nFh+Lr{+YdJhu$hW;82tb;{zBv&P^>*OxWN%v9d+q0M9yRFx;^^X1-HRI5wL!+S zbD1+vS4~b5Mes|(Y-}H8O)2U6HmyX(kP#=q)*(g+zxAO{58d%F#o|zL%R-U=aiuAa z(o2!~Z!R9bVi;g+t2NSAw`%0+IXNV*IL%h~R9p?BYT(B2H+}c691M^IIu<-sgvMW- zmr|V9%$|(I@ABh~kl{q(v8>m>^VgE0=1F*gf7;+EnUzCh)mlM3$~G*@P+O550=&hm zLClW}^`^2R00I}vkPFLyS;;f>*ImKsVvUI^XhLXBh3<% zc9)tMnLV)yGK?+yD3X25g8%CE(HkE!i|+3JDEGzF$ET1J54$O_alWxRU<~gx`p$;= zvU5WUMe*js86CY`h5VzD+4XckQz{acOKjm+cw}Itxs;m^mHC(C%asWNW*{P#2ked< znh_yNW2dui5v$Ao`guz0J10$58ynmW@@Crl1R|hxFrmWt*o%t~a-@QIVg&;O1IZ@V zOUQyqc`Iz9y-Tfm{mz<$Ixi1XFkx%aWThftwJDHiSTC7L8*=4)T zo{EgCC`OFk`9@ux2~+~s>8DIO4btP+WBEum>xhH$EV&*0=JZ;+639LNV={XRB^Tqk z(DXLygjc|pi_nM(SPJoZbd^g{U(tE9<>Tu*uX}HRRoU-4CC>?Tz|{j;>l~E)5A1X! Al>h($ literal 0 HcmV?d00001 diff --git a/src/webui/static/img/logo.png b/src/webui/static/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..169dfacce77524f9052a11bbddd5a2dd8fb17144 GIT binary patch literal 28727 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%0ADGjn4Co@A{GK#mAWXGq8=+PI;`^T#}H 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 literal 0 HcmV?d00001 diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js new file mode 100644 index 00000000..565e0755 --- /dev/null +++ b/src/webui/static/js/app.js @@ -0,0 +1,212 @@ +(function () { + function getCSRFToken() { + var meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute('content') : ''; + } + + function initCSRF() { + var token = getCSRFToken(); + if (!token || !window.htmx || !document.body) { + return; + } + document.body.addEventListener('htmx:configRequest', function (evt) { + evt.detail.headers['X-CSRF-Token'] = token; + }); + } + + function initDeviceStatusBanner() { + var banner = document.getElementById('conn-banner'); + if (!banner) { + return; + } + var down = false; + function check() { + fetch('/device-status').then(function (r) { + if (!r.ok) { + throw r; + } + if (down) { + down = false; + banner.hidden = true; + } + }).catch(function () { + if (!down) { + down = true; + banner.hidden = false; + } + }); + } + setInterval(check, 10000); + } + + function initProgressBars(root) { + var scope = root || document; + var bars = scope.querySelectorAll('.progress-bar[data-progress]'); + bars.forEach(function (bar) { + var val = parseInt(bar.getAttribute('data-progress'), 10); + if (!isNaN(val) && val >= 0) { + bar.style.width = Math.min(val, 100) + '%'; + } + }); + } + + function startRebootOverlay(root) { + var scope = root || document; + var overlay = scope.querySelector('.reboot-overlay'); + if (!overlay || overlay.dataset.init === 'true') { + return; + } + overlay.dataset.init = 'true'; + + var timeout = parseInt(overlay.getAttribute('data-timeout'), 10); + if (isNaN(timeout) || timeout <= 0) { + timeout = 120000; + } + var interval = parseInt(overlay.getAttribute('data-interval'), 10); + if (isNaN(interval) || interval <= 0) { + interval = 2000; + } + var start = Date.now(); + var status = overlay.querySelector('#reboot-status'); + var returnTo = window.location.pathname + window.location.search; + + function waitDown() { + if (Date.now() - start > timeout) { + if (status) { + status.textContent = 'Timeout - device did not shut down within 2 minutes.'; + status.classList.add('is-error'); + } + return; + } + fetch('/device-status').then(function (r) { + if (r.ok) { + setTimeout(waitDown, interval); + } else { + if (status) { + status.textContent = 'Device is down, waiting for it to come back...'; + } + setTimeout(waitUp, interval); + } + }).catch(function () { + if (status) { + status.textContent = 'Device is down, waiting for it to come back...'; + } + setTimeout(waitUp, interval); + }); + } + + function waitUp() { + if (Date.now() - start > timeout) { + if (status) { + status.textContent = 'Timeout - device did not respond within 2 minutes.'; + status.classList.add('is-error'); + } + return; + } + fetch('/device-status').then(function (r) { + if (r.ok) { + window.location = returnTo || '/'; + } else { + setTimeout(waitUp, interval); + } + }).catch(function () { + setTimeout(waitUp, interval); + }); + } + + setTimeout(waitDown, interval); + } + + function initDynamicUI(root) { + initProgressBars(root); + startRebootOverlay(root); + } + + document.addEventListener('DOMContentLoaded', function () { + initCSRF(); + initDeviceStatusBanner(); + initDynamicUI(document); + }); + + if (window.htmx && document.body) { + document.body.addEventListener('htmx:afterSwap', function (evt) { + initDynamicUI(evt.target); + }); + } +})(); + +// Dark mode toggle (auto / light / dark) +(function() { + function getTheme() { + var m = document.cookie.split(';').map(function(c){return c.trim();}).find(function(c){return c.indexOf('theme=')===0;}); + return m ? m.split('=')[1] : null; + } + function applyTheme(mode) { + var el = document.documentElement; + el.classList.remove('dark', 'light'); + if (mode === 'dark') { + el.classList.add('dark'); + } else if (mode === 'light') { + el.classList.add('light'); + } + updateIcon(mode || 'auto'); + } + function updateIcon(mode) { + var btn = document.getElementById('theme-toggle'); + if (!btn) return; + var icons = btn.querySelectorAll('svg'); + for (var i = 0; i < icons.length; i++) icons[i].style.display = 'none'; + var id = mode === 'dark' ? 'icon-dark' : mode === 'light' ? 'icon-light' : 'icon-auto'; + var active = btn.querySelector('#' + id); + if (active) active.style.display = ''; + btn.setAttribute('aria-label', + mode === 'dark' ? 'Theme: dark (click for auto)' : + mode === 'light' ? 'Theme: light (click for dark)' : + 'Theme: auto (click for light)'); + } + function setTheme(mode) { + if (mode) { + document.cookie = 'theme=' + mode + '; path=/; max-age=31536000; samesite=lax'; + } else { + document.cookie = 'theme=; path=/; max-age=0; samesite=lax'; + } + applyTheme(mode); + } + var saved = getTheme(); + applyTheme(saved); + document.addEventListener('DOMContentLoaded', function() { + var btn = document.getElementById('theme-toggle'); + if (btn) btn.addEventListener('click', function() { + var cur = getTheme(); + if (!cur) setTheme('light'); + else if (cur === 'light') setTheme('dark'); + else setTheme(null); + }); + }); +})(); + +// Sidebar toggle (mobile) +(function() { + document.addEventListener('DOMContentLoaded', function() { + var btn = document.getElementById('hamburger-btn'); + if (!btn) return; + btn.addEventListener('click', function() { + var open = document.body.classList.toggle('sidebar-open'); + btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + // Close sidebar when clicking the overlay (::after pseudo) + document.body.addEventListener('click', function(e) { + if (document.body.classList.contains('sidebar-open') && e.target === document.body) { + document.body.classList.remove('sidebar-open'); + btn.setAttribute('aria-expanded', 'false'); + } + }); + // Close sidebar when a nav link is clicked (htmx navigation) + document.addEventListener('htmx:beforeRequest', function() { + if (document.body.classList.contains('sidebar-open')) { + document.body.classList.remove('sidebar-open'); + if (btn) btn.setAttribute('aria-expanded', 'false'); + } + }); + }); +})(); diff --git a/src/webui/static/js/htmx.min.js b/src/webui/static/js/htmx.min.js new file mode 100644 index 00000000..59937d71 --- /dev/null +++ b/src/webui/static/js/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/src/webui/templates/fragments/iface-counters.html b/src/webui/templates/fragments/iface-counters.html new file mode 100644 index 00000000..e995bb01 --- /dev/null +++ b/src/webui/templates/fragments/iface-counters.html @@ -0,0 +1,45 @@ +{{define "iface-counters"}} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CounterRXTX
Bytes{{.Counters.RxBytes}}{{.Counters.TxBytes}}
Unicast{{.Counters.RxUnicast}}{{.Counters.TxUnicast}}
Broadcast{{.Counters.RxBroadcast}}{{.Counters.TxBroadcast}}
Multicast{{.Counters.RxMulticast}}{{.Counters.TxMulticast}}
Discards{{.Counters.RxDiscards}}{{.Counters.TxDiscards}}
Errors{{.Counters.RxErrors}}{{.Counters.TxErrors}}
+
+{{end}} diff --git a/src/webui/templates/layouts/base.html b/src/webui/templates/layouts/base.html new file mode 100644 index 00000000..f2278db1 --- /dev/null +++ b/src/webui/templates/layouts/base.html @@ -0,0 +1,36 @@ +{{define "base"}} + + + + + + + + + {{if .PageTitle}}{{.PageTitle}} — {{end}}Infix + + + + + + + +
+
+ +
+
+ {{template "sidebar" .}} +
+ {{block "content" .}}{{end}} +
+
+ + +{{end}} diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html new file mode 100644 index 00000000..6d9318b5 --- /dev/null +++ b/src/webui/templates/layouts/sidebar.html @@ -0,0 +1,175 @@ +{{define "sidebar"}} + +{{end}} diff --git a/src/webui/templates/pages/containers.html b/src/webui/templates/pages/containers.html new file mode 100644 index 00000000..d7a3496a --- /dev/null +++ b/src/webui/templates/pages/containers.html @@ -0,0 +1,52 @@ +{{define "containers.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +{{if .Error}}
{{.Error}}
{{end}} + +{{if .Containers}} +
+
Containers
+
+ + + + + + {{range .Containers}} + + + + + + + + + {{end}} + +
NameImageStatusCPUMemoryUptime
{{.Name}}{{.Image}} + + {{.Status}} + + {{if gt .CPUPct 0}} +
+
+
+ {{.CPUPct}}% + {{else}}{{end}} +
+ {{if .MemUsed}} +
+
+
+ {{.MemUsed}} / {{.MemLimit}} + {{else}}{{end}} +
{{if .Uptime}}{{.Uptime}}{{else}}—{{end}}
+
+
+{{else if not .Error}} +
+
Containers
+

No containers configured.

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/dashboard.html b/src/webui/templates/pages/dashboard.html new file mode 100644 index 00000000..3a68b52b --- /dev/null +++ b/src/webui/templates/pages/dashboard.html @@ -0,0 +1,103 @@ +{{define "dashboard.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +
+
+
System Information
+
+ + {{if .Hostname}}{{end}} + {{if .OSName}}{{end}} + {{if .Machine}}{{end}} + {{if .Firmware}}{{end}} + {{if .Uptime}}{{end}} +
Hostname{{.Hostname}}
OS{{.OSName}} {{.OSVersion}}
Architecture{{.Machine}}
Firmware{{.Firmware}}
Uptime{{.Uptime}}
+
+
+ +
+
Resources
+
+ + {{if .MemTotal}} + + + + + {{end}} + {{if .Load1}} + + + + + {{end}} +
Memory +
+
+
+ {{.MemUsed}} / {{.MemTotal}} MiB ({{.MemPercent}}%) +
Load Average + {{.Load1}} · {{.Load5}} · {{.Load15}} +
+
+
+ + {{if .Board.Model}} +
+
Hardware
+
+ + {{if .Board.Model}}{{end}} + {{if .Board.Manufacturer}}{{end}} + {{if .Board.SerialNum}}{{end}} + {{if .Board.HardwareRev}}{{end}} + {{if .Board.BaseMAC}}{{end}} + {{if .Sensors}} + {{range .Sensors}} + + {{end}} + {{end}} +
Model{{.Board.Model}}
Manufacturer{{.Board.Manufacturer}}
Serial Number{{.Board.SerialNum}}
Hardware Rev{{.Board.HardwareRev}}
Base MAC{{.Board.BaseMAC}}
{{.Name}}{{.Value}}
+
+
+ {{end}} +
+ +{{if .Disks}} +
+
Disk Usage
+
+
+ + + + + + + + + + + + {{range .Disks}} + + + + + + + + {{end}} + +
MountSizeUsedAvailableUse%
{{.Mount}}{{.Size}}{{.Used}}{{.Available}}{{.Percent}}%
+
+
+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/dhcp.html b/src/webui/templates/pages/dhcp.html new file mode 100644 index 00000000..4e2d4ef0 --- /dev/null +++ b/src/webui/templates/pages/dhcp.html @@ -0,0 +1,67 @@ +{{define "dhcp.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .DHCP}} +
+

DHCP Server

+ + + + + +
Status + + {{if .DHCP.Enabled}}Enabled{{else}}Disabled{{end}} +
+ +
+ Discovers{{.DHCP.Stats.InDiscoveries}} + Requests{{.DHCP.Stats.InRequests}} + Releases{{.DHCP.Stats.InReleases}} + Offers{{.DHCP.Stats.OutOffers}} + Acks{{.DHCP.Stats.OutAcks}} + Naks{{.DHCP.Stats.OutNaks}} +
+ + {{if .DHCP.Leases}} +

Active Leases

+
+ + + + + + + + + + + + {{range .DHCP.Leases}} + + + + + + + + {{end}} + +
IP AddressMAC AddressHostnameExpiresClient ID
{{.Address}}{{.MAC}}{{if .Hostname}}{{.Hostname}}{{else}}{{end}}{{.Expires}}{{if .ClientID}}{{.ClientID}}{{else}}{{end}}
+
+ {{else}} +

No active leases.

+ {{end}} +
+{{else if not .Error}} +
+

DHCP server data not available.

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/firewall.html b/src/webui/templates/pages/firewall.html new file mode 100644 index 00000000..828278c6 --- /dev/null +++ b/src/webui/templates/pages/firewall.html @@ -0,0 +1,122 @@ +{{define "firewall.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +
+
+
Firewall Status
+ + + + + + {{if .DefaultZone}} + + {{end}} + + + + + +
Firewall + + {{.EnabledText}} +
Default Zone{{.DefaultZone}}
Lockdown Mode{{if .Lockdown}}Active{{else}}Inactive{{end}}
Log Denied Traffic{{.Logging}}
+
+
+ +{{if .Matrix}} +
+
Zone Matrix
+
+ + + + + {{range .ZoneNames}}{{end}} + + + + {{range .Matrix}} + + + {{range .Cells}}{{end}} + + {{end}} + +
{{.}}
{{.Zone}}{{.Symbol}}
+
+ Allow + Deny +
+
+
+{{end}} + +{{if .Zones}} +
+
Zones
+
+ + + + + + + + + + + + {{range .Zones}} + + + + + + + + {{end}} + +
NameActionInterfacesNetworksServices
{{.Name}}{{.Action}}{{.Interfaces}}{{.Networks}}{{.Services}}
+
+
+{{end}} + +{{if .Policies}} +
+
Policies
+
+ + + + + + + + + + + + + {{range .Policies}} + + + + + + + + + {{end}} + +
NamePriorityActionIngressEgressServices
{{.Name}}{{.Priority}}{{.Action}}{{.Ingress}}{{.Egress}}{{.Services}}{{if .Masquerade}} masq{{end}}
+
+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/firmware.html b/src/webui/templates/pages/firmware.html new file mode 100644 index 00000000..5c3e0e17 --- /dev/null +++ b/src/webui/templates/pages/firmware.html @@ -0,0 +1,69 @@ +{{define "firmware.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .Message}} +
{{.Message}}
+{{end}} + +{{if .Installer}}{{if .Installer.Active}} +
+
Install in Progress
+
+
+
+

{{.Installer.Percentage}}% — {{.Installer.Message}}

+
+{{end}}{{end}} + +{{if .Installer}}{{if .Installer.LastError}} +
Last error: {{.Installer.LastError}}
+{{end}}{{end}} + +
+
Firmware Slots
+ {{if .Slots}} +
+ + + + + + + + + + {{range .Slots}} + + + + + + {{end}} + +
SlotStateVersion
{{.Name}}{{if .BootName}} ({{.BootName}}){{end}}{{if .Booted}} booted{{end}}{{.State}}{{if .Version}}{{.Version}}{{else}}-{{end}}
+
+ {{else}} +

No firmware slot information available.

+ {{end}} +
+ +
+
Install Firmware
+
+ +
+ + +
+ +
+
+{{end}} diff --git a/src/webui/templates/pages/iface-detail.html b/src/webui/templates/pages/iface-detail.html new file mode 100644 index 00000000..009ffbb1 --- /dev/null +++ b/src/webui/templates/pages/iface-detail.html @@ -0,0 +1,155 @@ +{{define "iface-detail.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+ ← Interfaces +

{{.Name}}

+
+ +
+
+
Interface Info
+ + + + + + + + + {{if .PhysAddr}}{{end}} + {{if .MTU}}{{end}} + {{if .Speed}}{{end}} + {{if .Duplex}}{{end}} + {{if .AutoNeg}}{{end}} + {{if .WiFiMode}}{{end}} + {{if .WiFiSSID}}{{end}} + {{if .WiFiSignal}}{{end}} + {{if .WiFiRxSpeed}}{{end}} + {{if .WiFiTxSpeed}}{{end}} + {{if .WiFiStationCount}}{{end}} + {{if .WGPeerSummary}}{{end}} +
Name{{.Name}}
Type{{.Type}}
Index{{.IfIndex}}
Status{{.Status}}
MAC Address{{.PhysAddr}}
MTU{{.MTU}}
Speed{{.Speed}}
Duplex{{.Duplex}}
Auto-negotiation{{.AutoNeg}}
Mode{{.WiFiMode}}
SSID{{.WiFiSSID}}
Signal{{.WiFiSignal}}
RX Speed{{.WiFiRxSpeed}}
TX Speed{{.WiFiTxSpeed}}
Connected Stations{{.WiFiStationCount}}
Peers{{.WGPeerSummary}}
+ {{if .Addresses}} +

Addresses

+ + {{range .Addresses}} + + + + {{end}} +
{{.Address}}{{if .Origin}} ({{.Origin}}){{end}}
+ {{end}} +
+ +
+
Counters
+ {{template "iface-counters" .}} +
+
+ +{{if .WiFiStations}} +
+
Connected Stations
+
+ + + + + + + + + + + + + + + + {{range .WiFiStations}} + + + + + + + + + + + + {{end}} + +
MACSignalTimeRX PktsTX PktsRX BytesTX BytesRX SpeedTX Speed
{{.MAC}}{{if .Signal}}{{.Signal}}{{end}}{{.Time}}{{.RxPkts}}{{.TxPkts}}{{.RxBytes}}{{.TxBytes}}{{.RxSpeed}}{{.TxSpeed}}
+
+
+{{end}} + +{{if .ScanResults}} +
+
Scan Results
+
+ + + + + + + + + + + + {{range .ScanResults}} + + + + + + + + {{end}} + +
SSIDBSSIDSignalChannelSecurity
{{if .SSID}}{{.SSID}}{{else}}Hidden{{end}}{{.BSSID}}{{if .Signal}}{{.Signal}}{{end}}{{.Channel}}{{.Encryption}}
+
+
+{{end}} + +{{if .WGPeers}} +
+
WireGuard Peers
+ {{range .WGPeers}} +
+ + + + + + + {{if .Endpoint}}{{end}} + {{if .Handshake}}{{end}} + {{if .TxBytes}}{{end}} + {{if .RxBytes}}{{end}} +
Public Key{{.PublicKey}}
Status{{.Status}}
Endpoint{{.Endpoint}}
Latest Handshake{{.Handshake}}
Transfer TX{{.TxBytes}}
Transfer RX{{.RxBytes}}
+
+ {{end}} +
+{{end}} + +{{if .EthFrameStats}} +
+
Ethernet Frame Statistics
+
+ {{range .EthFrameStats}} +
+
{{.Key}}
+
{{.Value}}
+
+ {{end}} +
+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/interfaces.html b/src/webui/templates/pages/interfaces.html new file mode 100644 index 00000000..6d611397 --- /dev/null +++ b/src/webui/templates/pages/interfaces.html @@ -0,0 +1,46 @@ +{{define "interfaces.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .Interfaces}} +
+
Interfaces
+
+ + + + + + + + + + + + + {{range .Interfaces}} + + + + + + + + + {{end}} + +
NameTypeStatusMACAddressDetail
{{if .Indent}}{{.Indent}}{{end}}{{.Name}}{{.Type}}{{.Status}}{{.PhysAddr}}{{range $i, $a := .Addresses}}{{if $i}}
{{end}}{{$a.Address}}{{if $a.Origin}} ({{$a.Origin}}){{end}}{{end}}
{{.Detail}}
+
+
+{{else if not .Error}} +
+
Interfaces
+

No interfaces found.

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/keystore.html b/src/webui/templates/pages/keystore.html new file mode 100644 index 00000000..6db22639 --- /dev/null +++ b/src/webui/templates/pages/keystore.html @@ -0,0 +1,74 @@ +{{define "keystore.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .Empty}} +
+
Keystore
+

Keystore is empty.

+
+{{end}} + +{{if .SymmetricKeys}} +
+
Symmetric Keys
+
+ + + + + + + + + + {{range .SymmetricKeys}} + + + + + + {{end}} + +
NameFormatValue
{{.Name}}{{.Format}}{{.Value}}
+
+
+{{end}} + +{{if .AsymmetricKeys}} +
+
Asymmetric Keys
+
+ + + + + + + {{if (index .AsymmetricKeys 0).PrivateKeyFull}}{{end}} + + + + + {{range .AsymmetricKeys}} + + + + + {{if .PrivateKeyFull}} + + {{end}} + + + {{end}} + +
NameAlgorithmPublic KeyPrivate KeyCertificates
{{.Name}}{{.Algorithm}}{{if .PublicKeyFull}}{{.PublicKeyFull}}{{else}}-{{end}}{{.PrivateKeyFull}}{{range $i, $c := .Certificates}}{{if $i}}, {{end}}{{$c}}{{end}}
+
+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/lldp.html b/src/webui/templates/pages/lldp.html new file mode 100644 index 00000000..941f6f5c --- /dev/null +++ b/src/webui/templates/pages/lldp.html @@ -0,0 +1,45 @@ +{{define "lldp.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .Neighbors}} +
+

LLDP Neighbors

+
+ + + + + + + + + + + + + {{range .Neighbors}} + + + + + + + + + {{end}} + +
Local PortChassis IDSystem NamePort IDPort DescMgmt Address
{{.LocalPort}}{{.ChassisID}}{{if .SystemName}}{{.SystemName}}{{else}}{{end}}{{if .PortID}}{{.PortID}}{{else}}{{end}}{{if .PortDesc}}{{.PortDesc}}{{else}}{{end}}{{if .MgmtAddress}}{{.MgmtAddress}}{{else}}{{end}}
+
+
+{{else if not .Error}} +
+

No LLDP neighbors discovered.

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/login.html b/src/webui/templates/pages/login.html new file mode 100644 index 00000000..78d5bb71 --- /dev/null +++ b/src/webui/templates/pages/login.html @@ -0,0 +1,40 @@ +{{define "login.html"}} + + + + + + + + Infix — Login + + + + + + + + + +{{end}} diff --git a/src/webui/templates/pages/ntp.html b/src/webui/templates/pages/ntp.html new file mode 100644 index 00000000..ccb7034a --- /dev/null +++ b/src/webui/templates/pages/ntp.html @@ -0,0 +1,74 @@ +{{define "ntp.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .NTP}} +
+

NTP

+ + + + + + {{if .NTP.Stratum}} + + {{end}} + {{if .NTP.RefID}} + + {{end}} + {{if .NTP.Offset}} + + {{end}} + {{if .NTP.RootDelay}} + + {{end}} +
Sync Status + + {{if .NTP.Synchronized}}Synchronized{{else}}Not Synchronized{{end}} +
Stratum{{.NTP.Stratum}}
Reference ID{{.NTP.RefID}}
Offset{{.NTP.Offset}}
Root Delay{{.NTP.RootDelay}}
+ + {{if .NTP.Associations}} +

Associations

+
+ + + + + + + + + + + + + + {{range .NTP.Associations}} + + + + + + + + + + {{end}} + +
ServerStratumRef IDReachPoll (s)OffsetDelay
{{.Address}}{{.Stratum}}{{.RefID}}{{.Reach}}{{.Poll}}{{.Offset}}{{.Delay}}
+
+ {{else}} +

No associations.

+ {{end}} +
+{{else if not .Error}} +
+

NTP data not available.

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/routing.html b/src/webui/templates/pages/routing.html new file mode 100644 index 00000000..9d9df7a5 --- /dev/null +++ b/src/webui/templates/pages/routing.html @@ -0,0 +1,113 @@ +{{define "routing.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+

Routing

+ {{if .Error}}
{{.Error}}
{{end}} + +
+

Routing Table

+ {{if .Routes}} +
+ + + + + + + + + + {{range .Routes}} + + + + + + + + {{end}} + +
DestinationNext HopProtocolPreferenceActive
{{.DestPrefix}}{{if .NextHopAddr}}{{.NextHopAddr}}{{else}}{{.NextHopIface}}{{end}} + {{if eq .Protocol "ospfv2"}}ospfv2 + {{else if eq .Protocol "static"}}static + {{else if eq .Protocol "kernel"}}kernel + {{else if eq .Protocol "direct"}}direct + {{else}}{{.Protocol}}{{end}} + {{.Preference}}{{if .Active}}{{else}}{{end}}
+
+ {{else}} +

No routes found.

+ {{end}} +
+ + {{if .HasOSPF}} +
+

OSPF Neighbors

+ {{if .OSPFNeighbors}} +
+ + + + + + + + + + {{range .OSPFNeighbors}} + + + + + + + + {{end}} + +
Router IDStateRoleInterfaceUptime
{{.RouterID}}{{.State}}{{.Role}}{{.Interface}}{{.Uptime}}
+
+ {{else}} +

No OSPF neighbors.

+ {{end}} +
+ +
+

OSPF Interfaces

+ {{if .OSPFIfaces}} +
+ + + + + + + + + + {{range .OSPFIfaces}} + + + + + + + + {{end}} + +
NameStateCostDRBDR
{{.Name}}{{.State}}{{.Cost}}{{.DR}}{{.BDR}}
+
+ {{else}} +

No OSPF interfaces.

+ {{end}} +
+ {{else}} +
+

OSPF

+

OSPF is not configured on this device.

+
+ {{end}} +
+{{end}} diff --git a/src/webui/templates/pages/vpn.html b/src/webui/templates/pages/vpn.html new file mode 100644 index 00000000..b272d58c --- /dev/null +++ b/src/webui/templates/pages/vpn.html @@ -0,0 +1,58 @@ +{{define "vpn.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .Tunnels}} +{{range .Tunnels}} +
+
+ + {{.Name}}{{if .ListenPort}} :{{.ListenPort}}{{end}} +
+ {{if .Addresses}} +
+ {{range .Addresses}}{{.}} {{end}} +
+ {{end}} + {{if .Peers}} +
+ + + + + + + + + + + + + {{range .Peers}} + + + + + + + + + {{end}} + +
Public KeyEndpointStatusLast HandshakeRXTX
{{.PublicKeyShort}}{{if .Endpoint}}{{.Endpoint}}{{else}}{{end}} {{.Status}}{{.LastHandshake}}{{.RxBytes}}{{.TxBytes}}
+
+ {{else}} +

No peers configured.

+ {{end}} +
+{{end}} +{{else if not .Error}} +
+
WireGuard
+

No WireGuard tunnels configured.

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/wifi.html b/src/webui/templates/pages/wifi.html new file mode 100644 index 00000000..58b48553 --- /dev/null +++ b/src/webui/templates/pages/wifi.html @@ -0,0 +1,148 @@ +{{define "wifi.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .Radios}} +{{range .Radios}} +
+
+
+
+ {{.Name}} +
+
+
+ + {{if .Manufacturer}}{{end}} + {{if .Driver}}{{end}} + {{if .Standards}}{{end}} + {{if .Band}}{{end}} + {{if .Channel}}{{end}} + {{if .Frequency}}{{end}} + {{if .Noise}}{{end}} + {{if .MaxAP}}{{end}} + {{if .Bands}} + + + + + {{end}} +
Manufacturer{{.Manufacturer}}
Driver{{.Driver}}
Standards{{.Standards}}
Active Band{{.Band}}
Channel{{.Channel}}
Frequency{{.Frequency}} MHz
Noise Floor{{.Noise}} dBm
Max APs{{.MaxAP}}
Supported Bands +
+ {{range .Bands}} +
+ {{.Name}} +
+ {{end}} +
+
+
+
+ + {{if .SurveySVG}} +
+
Channel Survey
+
+ {{.SurveySVG}} +
+
+ {{end}} +
+ +{{range .Interfaces}} +
+
+
+ + + {{.Name}} + {{if .SSID}}{{.SSID}}{{end}} + + {{if .Mode}}{{.Mode}}{{end}} +
+
+ + {{if eq .Mode "station"}} +
+ + {{if .Signal}}{{end}} + {{if .RxSpeed}}{{end}} + {{if .TxSpeed}}{{end}} +
Signal{{.Signal}}
RX Speed{{.RxSpeed}}
TX Speed{{.TxSpeed}}
+
+ {{end}} + + {{if and (eq .Mode "ap") .APClients}} +
+ + + + + + + + + + + + + + {{range .APClients}} + + + + + + + + + + {{end}} + +
MAC AddressSignalConnectedRX BytesTX BytesRX SpeedTX Speed
{{.MAC}}{{if .Signal}}{{.Signal}}{{else}}—{{end}}{{.ConnTime}}{{.RxBytes}}{{.TxBytes}}{{.RxSpeed}}{{.TxSpeed}}
+
+ {{else if eq .Mode "ap"}} +

No stations connected.

+ {{end}} + + {{if and (eq .Mode "station") .ScanResults}} +
+ + + + + + + + + + + + {{range .ScanResults}} + + + + + + + + {{end}} + +
SSIDBSSIDSignalChannelSecurity
{{if .SSID}}{{.SSID}}{{else}}Hidden{{end}}{{.BSSID}}{{if .Signal}}{{.Signal}}{{else}}—{{end}}{{.Channel}}{{.Encryption}}
+
+ {{end}} +
+{{end}} +{{end}} +{{else if not .Error}} +
+
WiFi
+

No WiFi radios detected.

+
+{{end}} +{{end}} From 6271e40ce034dd0f815e9cbfff5008fffc2cdd7b Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 28 Mar 2026 20:23:46 +0100 Subject: [PATCH 06/59] webui: accordion sidebar and general-purpose status badges Replace flat nav-section-header labels with collapsible
/ accordion groups (Network, Wireless, VPN, Services, System). Groups default open; user state persists via localStorage. Tablet icon-only mode always shows all items regardless of open/closed state. Add badge-up/down/warning/info/neutral utility classes with full dark mode support, complementing the existing firewall-specific badge variants. Signed-off-by: Joachim Wiberg --- src/webui/static/css/style.css | 71 +++++- src/webui/static/js/app.js | 20 ++ src/webui/templates/layouts/sidebar.html | 280 ++++++++++++----------- 3 files changed, 236 insertions(+), 135 deletions(-) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 2a6c6980..9f69573d 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -182,20 +182,50 @@ a:hover { } .sidebar-nav { - list-style: none; flex: 1; - padding: 1rem 0; + padding: 0.5rem 0; + overflow-y: auto; } -.nav-section-header { +/* Accordion Nav Groups */ +details.nav-group { + border-top: 1px solid var(--sidebar-border); +} +details.nav-group:first-child { + border-top: none; +} + +details.nav-group > summary.nav-group-summary { + list-style: none; + cursor: pointer; font-size: 0.65rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--sidebar-fg); opacity: 0.5; - padding: 1rem 1rem 0.25rem; - pointer-events: none; + padding: 0.875rem 1rem 0.375rem; + display: flex; + justify-content: space-between; + align-items: center; + user-select: none; + transition: opacity 0.15s; +} +details.nav-group > summary.nav-group-summary:hover { opacity: 0.8; } +details.nav-group > summary.nav-group-summary::-webkit-details-marker { display: none; } +details.nav-group > summary.nav-group-summary::marker { display: none; } + +details.nav-group > summary.nav-group-summary::after { + content: '▸'; + font-size: 0.7rem; +} +details[open].nav-group > summary.nav-group-summary::after { + content: '▾'; +} + +.nav-group-items { + list-style: none; + padding-bottom: 0.5rem; } .nav-icon { @@ -796,6 +826,31 @@ a:hover { .dark .badge-drop { background: var(--slate-800); color: var(--slate-400); } .dark .badge-continue { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } +/* General-purpose status badges */ +.badge-up { background: #dcfce7; color: #166534; } +.badge-down { background: #fef2f2; color: #991b1b; } +.badge-warning { background: #fef9c3; color: #854d0e; } +.badge-info { background: #dbeafe; color: #1e40af; } +.badge-neutral { background: var(--slate-100); color: var(--slate-600); } + +@media (prefers-color-scheme: dark) { + .badge-up { background: rgba(22, 163, 74, 0.2); color: #86efac; } + .badge-down { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } + .badge-warning { background: rgba(245, 158, 11, 0.2); color: #fcd34d; } + .badge-info { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } + .badge-neutral { background: var(--slate-800); color: var(--slate-400); } +} +.dark .badge-up { background: rgba(22, 163, 74, 0.2); color: #86efac; } +.dark .badge-down { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } +.dark .badge-warning { background: rgba(245, 158, 11, 0.2); color: #fcd34d; } +.dark .badge-info { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } +.dark .badge-neutral { background: var(--slate-800); color: var(--slate-400); } +.light .badge-up { background: #dcfce7; color: #166534; } +.light .badge-down { background: #fef2f2; color: #991b1b; } +.light .badge-warning { background: #fef9c3; color: #854d0e; } +.light .badge-info { background: #dbeafe; color: #1e40af; } +.light .badge-neutral { background: var(--slate-100); color: var(--slate-600); } + /* Interfaces */ .iface-name { white-space: nowrap; font-weight: 500; } .iface-name a { color: var(--primary); text-decoration: none; } @@ -1047,9 +1102,13 @@ a:hover { display: none; } - .nav-section-header { + /* Accordion in icon-only mode: always show items, hide section headers */ + details.nav-group > summary.nav-group-summary { display: none; } + details.nav-group:not([open]) > .nav-group-items { + display: block !important; + } .nav-link { justify-content: center; diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 565e0755..81119d08 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -185,6 +185,26 @@ }); })(); +// Accordion nav group persistence +(function() { + document.addEventListener('DOMContentLoaded', function() { + document.querySelectorAll('details.nav-group').forEach(function(d) { + var label = d.querySelector('summary'); + if (!label) return; + var key = 'nav-group:' + label.textContent.trim(); + var saved = localStorage.getItem(key); + if (saved === 'closed') { + d.removeAttribute('open'); + } else if (saved === 'open') { + d.setAttribute('open', ''); + } + d.addEventListener('toggle', function() { + localStorage.setItem(key, d.open ? 'open' : 'closed'); + }); + }); + }); +})(); + // Sidebar toggle (mobile) (function() { document.addEventListener('DOMContentLoaded', function() { diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index 6d9318b5..1947ca17 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -3,141 +3,163 @@ - + + +
{{template "sidebar" .}} -
- {{block "content" .}}{{end}} -
+
+
+ +
+
+ +
+ + + + + + + + Download Config + + + +
+ + +
+
+
+
+
+
+ {{block "content" .}}{{end}} +
+
diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index 1947ca17..da54e7e7 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -160,38 +160,5 @@
- {{end}} diff --git a/src/webui/templates/pages/login.html b/src/webui/templates/pages/login.html index 78d5bb71..ac6530a7 100644 --- a/src/webui/templates/pages/login.html +++ b/src/webui/templates/pages/login.html @@ -6,7 +6,7 @@ - Infix — Login + Login @@ -14,26 +14,52 @@ @@ -90,7 +90,7 @@ {{range .Disks}}
- {{.Mount}} + {{.Mount}}{{if .ReadOnly}} read-only{{end}} {{.Percent}}%
diff --git a/src/webui/templates/pages/firmware.html b/src/webui/templates/pages/firmware.html deleted file mode 100644 index 14e87875..00000000 --- a/src/webui/templates/pages/firmware.html +++ /dev/null @@ -1,159 +0,0 @@ -{{define "firmware.html"}} -{{template "base" .}} -{{end}} - -{{define "content"}} -{{if .Error}} -
{{.Error}}
-{{end}} - -{{if .Message}} -
{{.Message}}
-{{end}} - -{{$sseURL := "/firmware/progress"}}{{if .AutoReboot}}{{$sseURL = "/firmware/progress?auto-reboot=1"}}{{end}} - -{{if .Installing}} -
- {{template "fw-progress-body" .}} -
-{{end}} - -
- -
-
Software
- {{if .BootOrder}} -
- Boot order - - {{range .BootOrder}}{{.}}{{end}} - - - -
- {{end}} - {{if .Slots}} -
- {{range .Slots}} -
-
- {{.Name}} - {{if .Booted}}booted - {{else}}{{.State}}{{end}} -
-
{{if .Version}}{{.Version}}{{else}}—{{end}}
- {{if .InstallDate}}
{{.InstallDate}}
{{end}} -
- {{end}} -
- {{else}} -

No software information available.

- {{end}} -
- -
-
Install from URL
-
-

- Get the latest firmware from - GitHub Releases. - Download the .tar.gz tarball, extract the .pkg file, and serve it over FTP/HTTP. -

-
- How to host the firmware from your PC -
-

Extract and serve the package with Python (Linux/macOS/Windows):

- tar xf infix-{{if .Machine}}{{.Machine}}{{else}}aarch64{{end}}-vVERSION.tar.gz -python3 -m http.server 8080 -

Then enter the URL below pointing at the .pkg file on your machine.

-
-
- - -
- - - -
- - - -
-
- -
-
Upload & Install
-
-

- Select a .pkg firmware file from your computer to upload and install directly. -

-
- - -
- -
- -
-
-
- - -
-{{end}} - -{{define "fw-progress-body"}} -{{if and .Installer .Installer.Done}} - {{if .Installer.Success}} -
Install Complete
-
-
- - Firmware installed successfully. -
- {{if not .AutoReboot}} -
- -
- {{end}} -
- {{else}} -
Install Failed
-
-
- - {{.Installer.LastError}} -
-
- {{end}} -{{else}} -
Install in Progress
-
-
-
-
-

{{if .Installer}}{{.Installer.Percentage}}% — {{.Installer.Message}}{{else}}Installing…{{end}}

-
-{{end}} -{{end}} diff --git a/src/webui/templates/pages/login.html b/src/webui/templates/pages/login.html index efd7b985..7c5d7ac3 100644 --- a/src/webui/templates/pages/login.html +++ b/src/webui/templates/pages/login.html @@ -17,10 +17,7 @@ diff --git a/src/webui/templates/pages/software.html b/src/webui/templates/pages/software.html new file mode 100644 index 00000000..94500601 --- /dev/null +++ b/src/webui/templates/pages/software.html @@ -0,0 +1,147 @@ +{{define "software.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .Message}} +
{{.Message}}
+{{end}} + +{{$sseURL := "/software/progress"}}{{if .AutoReboot}}{{$sseURL = "/software/progress?auto-reboot=1"}}{{end}} + +{{if .Installing}} +
+ {{template "sw-progress-body" .}} +
+{{end}} + +
+ +
+
Software
+ {{if .BootOrder}} +
+ Boot order + + {{range .BootOrder}}{{.}}{{end}} + + + +
+ {{end}} + {{if .Slots}} +
+ {{range .Slots}} +
+
+ {{.Name}} + {{if .Booted}}booted + {{else}}{{.State}}{{end}} +
+
{{if .Version}}{{.Version}}{{else}}—{{end}}
+ {{if .InstallDate}}
{{.InstallDate}}
{{end}} +
+ {{end}} +
+ {{else}} +

No software information available.

+ {{end}} +
+ +
+
Install from URL
+
+

+ Fetch a .pkg software bundle from a URL and install directly. +

+
+ +
+ + + +
+ + +
+
+
+ +
+
Upload & Install
+
+

+ Select a .pkg software bundle from your computer to upload and install directly. +

+
+ + +
+ +
+ +
+
+
+ + +
+{{end}} + +{{define "sw-progress-body"}} +{{if and .Installer .Installer.Done}} + {{if .Installer.Success}} +
Install Complete
+
+
+ {{template "icon-check-lg"}} + Software installed successfully. +
+ {{if not .AutoReboot}} +
+ +
+ {{end}} +
+ {{else}} +
Install Failed
+
+
+ {{template "icon-circle-x"}} + {{.Installer.LastError}} +
+
+ {{end}} +{{else}} +
Install in Progress
+
+
+
+
+

{{if .Installer}}{{.Installer.Percentage}}% — {{.Installer.Message}}{{else}}Installing…{{end}}

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/system-control.html b/src/webui/templates/pages/system-control.html index 5ee02889..aa74be04 100644 --- a/src/webui/templates/pages/system-control.html +++ b/src/webui/templates/pages/system-control.html @@ -6,67 +6,43 @@
-
Reboot
-
-

Restart the device. The running configuration is preserved. - Any unsaved changes (running config not yet copied to startup) will be lost on reboot.

-
+
Power
+
+ +
+
+ Reboot{{template "field-info" "Restart the device. The running configuration is preserved. Any unsaved changes (running config not yet copied to startup) will be lost on reboot."}} +
-
-
-
-
Shutdown
-
-

Power off the device. Physical access is required to bring it back online.

-
+
+ +
+
+ Shutdown{{template "field-info" "Power off the device. Physical access is required to bring it back online."}} +
+
-
-
Date & Time
-
-

- {{if .CurrentDatetime}}Device clock: {{.CurrentDatetime}}.{{else}}Device clock unavailable.{{end}} -

-
- -
- - -
-
-
- -
-
-
-
- -
-
Factory Reset
+
+
Danger Zone
- Reset running config -

Replaces the running configuration with factory defaults. - No reboot — takes effect immediately. Use the persistent notification to save to startup.

+ Reset running config{{template "field-info" "Replaces the running configuration with factory defaults. No reboot — takes effect immediately. Use the persistent notification to save to startup."}}
+ {{end}} +
+ + {{template "logs-body" .}} +
+{{end}} + +{{define "logs-body"}} +
+ + + {{if .Source.Lines}} +
+ {{/* sourceContent's field names mirror earlierData so the same + logs-earlier template renders both the initial buffer (.Source) + and the Earlier-handler response. */}} + {{template "logs-earlier" .Source}} +
+
+ Source: {{.Source.Path}} +
+ {{else}} +

No entries in {{.Source.Path}} yet.

+ {{end}} +
+{{end}} + +{{/* Combined load-more button + lines fragment. Used both as the + initial buffer and as the response to /earlier requests — the old + button is replaced via outerHTML by [new button, new lines], + keeping chronology in the DOM correct. + + The button's label depends on whether the next click stays in the + current file ("Load earlier") or crosses into a rotation + ("Load previous from messages.0"). */}} +{{define "logs-earlier"}} +{{with .Next}} +{{end}}{{range .Lines}}
{{.Text}}
+{{end}}{{end}} From dc2d4290937630683c192ca6a2e899f696d11ff5 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 13 Jun 2026 16:27:39 +0200 Subject: [PATCH 41/59] webui: add Maintenance > Diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Diagnostics page under Maintenance runs ping, traceroute, mtr and nmap with live output, plus a one-shot DNS lookup. A tab per tool shares a target field, source-interface and address-family selectors, and per-tool options; the field is focused on load and Enter runs the active tool from any field. Ping and traceroute stream their output line by line over SSE. mtr runs --raw and its records are translated into a live hop table — Loss%, Snt, Last, Avg, Best, Worst updating in place — running continuously until Stop, or for a set number of cycles. nmap offers a fixed set of scan profiles (quick, standard, service/version, host discovery); a CIDR target sweeps a subnet. Address family is forced with an explicit -4/-6 flag on the base binary, so a dual-stack hostname no longer follows the system's IPv6-first resolver preference on hosts that only have IPv4 routes. The streamer hands the child a real OS pipe and reaps it on exit, so a client disconnect (Stop closing the EventSource) kills the tool without leaking processes. User input is guarded: targets reject a leading dash and whitespace, the source interface is allow-listed against configured interfaces, and arguments are always passed as a slice. The target field keeps a history in localStorage. Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/common.go | 2 +- src/webui/internal/handlers/diagnostics.go | 495 +++++++++++++++++++++ src/webui/internal/server/server.go | 8 + src/webui/static/css/style.css | 86 ++++ src/webui/static/img/icons/README.md | 1 + src/webui/static/img/icons/diagnostics.svg | 1 + src/webui/static/js/app.js | 352 +++++++++++++++ src/webui/templates/layouts/sidebar.html | 12 +- src/webui/templates/pages/diagnostics.html | 149 +++++++ 9 files changed, 1104 insertions(+), 2 deletions(-) create mode 100644 src/webui/internal/handlers/diagnostics.go create mode 100644 src/webui/static/img/icons/diagnostics.svg create mode 100644 src/webui/templates/pages/diagnostics.html diff --git a/src/webui/internal/handlers/common.go b/src/webui/internal/handlers/common.go index 3699a8c1..6a513867 100644 --- a/src/webui/internal/handlers/common.go +++ b/src/webui/internal/handlers/common.go @@ -38,7 +38,7 @@ func csrfToken(ctx context.Context) string { // where it lives in the sidebar. func pageContext(page string) string { switch page { - case "software", "logs", "backup", "system-control": + case "software", "logs", "diagnostics", "backup", "system-control": return "Maintenance" } if strings.HasPrefix(page, "configure-") { diff --git a/src/webui/internal/handlers/diagnostics.go b/src/webui/internal/handlers/diagnostics.go new file mode 100644 index 00000000..6b499bcf --- /dev/null +++ b/src/webui/internal/handlers/diagnostics.go @@ -0,0 +1,495 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "html/template" + "io" + "log" + "net/http" + "net/url" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "infix/webui/internal/restconf" +) + +// DiagnosticsHandler serves the Maintenance > Diagnostics page: ping, +// traceroute, mtr (interactive hop table) and DNS lookup. Long-running +// tools stream over SSE, mirroring the software-progress and logs-tail +// handlers; client disconnect (the Stop button closing the EventSource) +// cancels the request context, which CommandContext turns into a kill + +// reap of the spawned tool. +type DiagnosticsHandler struct { + RC *restconf.Client + Template *template.Template +} + +type diagPageData struct { + PageData + Interfaces []string + Active string +} + +// Overview renders the Diagnostics page with Ping pre-selected. +// GET /maintenance/diagnostics +func (h *DiagnosticsHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := diagPageData{ + PageData: newPageData(w, r, "diagnostics", "Diagnostics"), + Interfaces: h.interfaceNames(r), + Active: "ping", + } + tmplName := "diagnostics.html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// interfaceNames returns configured interface names for the source +// dropdown, best-effort: an error just yields an empty list (the form +// still offers "Auto"). +func (h *DiagnosticsHandler) interfaceNames(r *http.Request) []string { + if h.RC == nil { + return nil + } + var wrap interfacesWrapper + if err := h.RC.Get(r.Context(), "/data/ietf-interfaces:interfaces", &wrap); err != nil { + log.Printf("diagnostics: interface list: %v", err) + return nil + } + names := make([]string, 0, len(wrap.Interfaces.Interface)) + for _, i := range wrap.Interfaces.Interface { + names = append(names, i.Name) + } + return names +} + +// validIface returns iface only if it's a real configured interface; +// otherwise "" (treated as Auto). An allow-list is the safe way to let +// a user-supplied string reach a -I/-i flag. +func (h *DiagnosticsHandler) validIface(r *http.Request, iface string) string { + if iface == "" { + return "" + } + for _, n := range h.interfaceNames(r) { + if n == iface { + return iface + } + } + return "" +} + +// ─── Input validation ──────────────────────────────────────────────── + +// validTarget guards the host/target argument before it reaches a +// spawned tool. Rejects empty input, anything with whitespace, and a +// leading '-' (which a tool would parse as a flag — argument injection). +// Args are always passed as an explicit slice, never a shell string, so +// this plus the leading-dash check is sufficient. +func validTarget(s string) bool { + if s == "" || len(s) > 255 { + return false + } + if strings.HasPrefix(s, "-") { + return false + } + for _, r := range s { + if r <= ' ' || r == 0x7f { + return false + } + } + return true +} + +func clampInt(s string, def, min, max int) int { + n, err := strconv.Atoi(s) + if err != nil { + return def + } + if n < min { + return min + } + if n > max { + return max + } + return n +} + +// ─── SSE helpers ───────────────────────────────────────────────────── + +func sseLine(w io.Writer, f http.Flusher, s string) { + // SSE data fields must be single-line; collapse any CR/LF. + s = strings.ReplaceAll(strings.ReplaceAll(s, "\r", ""), "\n", " ") + fmt.Fprintf(w, "event: line\ndata: %s\n\n", s) + f.Flush() +} + +func sseDone(w io.Writer, f http.Flusher) { + fmt.Fprint(w, "event: done\ndata: end\n\n") + f.Flush() +} + +// ─── Run ───────────────────────────────────────────────────────────── + +// diagCommand maps the request to a binary + argument slice, or ok=false +// for an unknown tool. +// +// Address family is forced with an explicit -4/-6 flag on the BASE binary +// rather than via the ping6/traceroute6 name. A dual-stack hostname (A + +// AAAA) otherwise follows the system's getaddrinfo preference — usually +// IPv6 — so "IPv4" never actually took effect on hosts that only have v4 +// routes. ping, traceroute and mtr all accept -4/-6; nmap defaults to v4 +// and only needs -6. "auto" passes no flag and lets the tool decide +// (IPv6 literals and v4-only names resolve correctly without help). +func diagCommand(tool, target, family, iface string, q url.Values) (bin string, args []string, ok bool) { + fam := "" + switch family { + case "4": + fam = "-4" + case "6": + fam = "-6" + } + + switch tool { + case "ping": + bin = "ping" + args = []string{"-c", strconv.Itoa(clampInt(q.Get("count"), 3, 1, 100))} + if fam != "" { + args = append(args, fam) + } + if sz := q.Get("size"); sz != "" { + args = append(args, "-s", strconv.Itoa(clampInt(sz, 56, 0, 65500))) + } + if iface != "" { + args = append(args, "-I", iface) + } + args = append(args, "-W", "2", target) + return bin, args, true + + case "traceroute": + bin = "traceroute" + args = []string{"-n", "-m", strconv.Itoa(clampInt(q.Get("maxhops"), 30, 1, 64))} + if fam != "" { + args = append(args, fam) + } + if iface != "" { + args = append(args, "-i", iface) + } + args = append(args, target) + return bin, args, true + + case "mtr": + bin = "mtr" + args = []string{"--raw", "-n"} + if fam != "" { + args = append(args, fam) + } + // A count means the user opted out of the default run-forever + // mode; otherwise mtr streams continuously until Stop. + if c := q.Get("count"); c != "" { + args = append(args, "-c", strconv.Itoa(clampInt(c, 10, 1, 1000))) + } + if sz := q.Get("size"); sz != "" { + args = append(args, "-s", strconv.Itoa(clampInt(sz, 56, 0, 65500))) + } + if iface != "" { + args = append(args, "-I", iface) + } + args = append(args, target) + return bin, args, true + + case "nmap": + bin = "nmap" + // -n: skip rDNS so scans stay quick and don't hang on slow + // resolvers. Scan profiles map to a fixed flag set — we never + // pass free-form nmap options from the client. + args = []string{"-n"} + if family == "6" { + args = append(args, "-6") + } + switch q.Get("scan") { + case "quick": + args = append(args, "-F") // top 100 ports + case "services": + args = append(args, "-sV") // service/version detection + case "ping": + args = append(args, "-sn") // host discovery only + case "standard": + // default top-1000 port scan, no extra flag + } + if iface != "" { + args = append(args, "-e", iface) + } + args = append(args, target) + return bin, args, true + } + return "", nil, false +} + +// Run streams tool output as SSE. Event protocol: +// +// event: line — a text line to append (ping, traceroute) +// event: hop — JSON hop stats to upsert into the mtr table +// event: done — the tool exited; client closes the stream +// : comment — keep-alive +// +// GET /maintenance/diagnostics/run?tool=&target=&iface=&family=&... +func (h *DiagnosticsHandler) Run(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + q := r.URL.Query() + tool := q.Get("tool") + target := q.Get("target") + family := q.Get("family") + if family == "" { + family = "auto" + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + // A few bytes up front so the proxy commits and EventSource.onopen + // fires promptly even before the tool emits anything. + fmt.Fprint(w, ": stream opened\n\n") + flusher.Flush() + + if !validTarget(target) { + sseLine(w, flusher, "Error: invalid target") + sseDone(w, flusher) + return + } + iface := h.validIface(r, q.Get("iface")) + + bin, args, ok := diagCommand(tool, target, family, iface, q) + if !ok { + sseLine(w, flusher, "Error: unknown tool") + sseDone(w, flusher) + return + } + + if tool == "mtr" { + h.streamMtr(w, r, flusher, bin, args) + return + } + h.streamLines(w, r, flusher, bin, args, func(line string) { + sseLine(w, flusher, line) + }) +} + +// streamLines spawns bin/args and invokes onLine for each output line, +// emitting a done event on exit. stdout and stderr are merged so tool +// errors ("Network is unreachable", "unknown host") reach the user. +func (h *DiagnosticsHandler) streamLines(w http.ResponseWriter, r *http.Request, flusher http.Flusher, bin string, args []string, onLine func(string)) { + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + cmd := exec.CommandContext(ctx, bin, args...) + + // Real OS pipe (not io.Pipe): the write-end fd is handed directly to + // the child, so there's no os/exec copy goroutine that could deadlock + // when the reader stops draining. When the child dies — including + // the CommandContext SIGKILL on ctx cancel — every write end closes + // and our read sees EOF, no matter whether anyone is reading. + pr, pw, err := os.Pipe() + if err != nil { + sseLine(w, flusher, "Error: "+err.Error()) + sseDone(w, flusher) + return + } + cmd.Stdout = pw + cmd.Stderr = pw + if err := cmd.Start(); err != nil { + pw.Close() + pr.Close() + sseLine(w, flusher, "Error: "+err.Error()) + sseDone(w, flusher) + return + } + pw.Close() // child holds the only write end now + defer pr.Close() // unblocks the scanner if we leave first + // Reap the child so it doesn't linger as a zombie; ctx cancel kills + // it, Wait then returns. + go cmd.Wait() //nolint:errcheck + + lines := make(chan string, 128) + go func() { + defer close(lines) + sc := bufio.NewScanner(pr) + sc.Buffer(make([]byte, 64*1024), 256*1024) + for sc.Scan() { + select { + case lines <- sc.Text(): + case <-ctx.Done(): + return + } + } + }() + + heartbeat := time.NewTicker(15 * time.Second) + defer heartbeat.Stop() + for { + select { + case <-ctx.Done(): + return + case line, ok := <-lines: + if !ok { + sseDone(w, flusher) + return + } + onLine(line) + case <-heartbeat.C: + fmt.Fprint(w, ": keep-alive\n\n") + flusher.Flush() + } + } +} + +// mtrHop accumulates per-hop statistics from the raw record stream. +type mtrHop struct { + host string + sent, recv int + last, best, worst float64 + sum float64 +} + +// streamMtr runs mtr --raw and translates its record stream into hop +// updates. Infix's mtr emits an `x ` record for every probe +// SENT (not just replies), so loss is exact: 100*(sent-recv)/sent. +// +// x probe sent for hop idx +// h
host discovered at hop idx +// p reply for hop idx, RTT in microseconds +func (h *DiagnosticsHandler) streamMtr(w http.ResponseWriter, r *http.Request, flusher http.Flusher, bin string, args []string) { + hops := map[int]*mtrHop{} + get := func(idx int) *mtrHop { + hop := hops[idx] + if hop == nil { + hop = &mtrHop{host: "???"} + hops[idx] = hop + } + return hop + } + emit := func(idx int, hop *mtrHop) { + loss := 0.0 + if hop.sent > 0 { + loss = 100 * float64(hop.sent-hop.recv) / float64(hop.sent) + } + avg := 0.0 + if hop.recv > 0 { + avg = hop.sum / float64(hop.recv) + } + payload, _ := json.Marshal(map[string]any{ + "idx": idx, "host": hop.host, "loss": loss, "snt": hop.sent, + "last": hop.last, "avg": avg, "best": hop.best, "worst": hop.worst, + }) + fmt.Fprintf(w, "event: hop\ndata: %s\n\n", payload) + flusher.Flush() + } + + h.streamLines(w, r, flusher, bin, args, func(line string) { + f := strings.Fields(line) + if len(f) < 2 { + return + } + idx, err := strconv.Atoi(f[1]) + if err != nil { + return + } + switch f[0] { + case "x": // probe sent + hop := get(idx) + hop.sent++ + emit(idx, hop) + case "h": // host discovered + if len(f) >= 3 { + get(idx).host = f[2] + } + case "p": // reply received + if len(f) >= 3 { + usec, err := strconv.Atoi(f[2]) + if err != nil { + return + } + rtt := float64(usec) / 1000 + hop := get(idx) + hop.recv++ + hop.last = rtt + hop.sum += rtt + if hop.recv == 1 || rtt < hop.best { + hop.best = rtt + } + if rtt > hop.worst { + hop.worst = rtt + } + emit(idx, hop) + } + } + }) +} + +// ─── DNS lookup ────────────────────────────────────────────────────── + +// Resolve does a one-shot name lookup via getent and returns an HTML +// fragment for the client to swap into the result pane. +// GET /maintenance/diagnostics/resolve?name=&family= +func (h *DiagnosticsHandler) Resolve(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + render := func(data map[string]any) { + if err := h.Template.ExecuteTemplate(w, "diag-dns-result", data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + } + if !validTarget(name) { + render(map[string]any{"Name": name, "Error": "invalid name"}) + return + } + + prog := "ahosts" + switch r.URL.Query().Get("family") { + case "4": + prog = "ahostsv4" + case "6": + prog = "ahostsv6" + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + out, _ := exec.CommandContext(ctx, "getent", prog, name).Output() + + // getent ahosts prints each address three times (STREAM/DGRAM/RAW); + // keep the first occurrence of each, preserving order. + var addrs []string + seen := map[string]bool{} + sc := bufio.NewScanner(strings.NewReader(string(out))) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) == 0 || seen[fields[0]] { + continue + } + seen[fields[0]] = true + addrs = append(addrs, fields[0]) + } + + if len(addrs) == 0 { + render(map[string]any{"Name": name, "Error": "no addresses found"}) + return + } + render(map[string]any{"Name": name, "Addrs": addrs}) +} diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 9d544ac3..9036ef6a 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -69,6 +69,10 @@ func New( if err != nil { return nil, err } + diagTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/diagnostics.html") + if err != nil { + return nil, err + } routingTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/routing.html") if err != nil { return nil, err @@ -240,6 +244,7 @@ func New( BackupTmpl: backupTmpl, } logs := &handlers.LogsHandler{Template: logsTmpl} + diag := &handlers.DiagnosticsHandler{RC: rc, Template: diagTmpl} routing := &handlers.RoutingHandler{Template: routingTmpl, RC: rc} wifi := &handlers.WiFiHandler{Template: wifiTmpl, RC: rc} @@ -310,6 +315,9 @@ func New( mux.HandleFunc("GET /maintenance/logs/{name}/earlier", logs.Earlier) mux.HandleFunc("GET /maintenance/logs/{name}/tail", logs.Tail) mux.HandleFunc("GET /maintenance/logs/{name}/download", logs.Download) + mux.HandleFunc("GET /maintenance/diagnostics", diag.Overview) + mux.HandleFunc("GET /maintenance/diagnostics/run", diag.Run) + mux.HandleFunc("GET /maintenance/diagnostics/resolve", diag.Resolve) mux.HandleFunc("GET /maintenance/backup", sys.Backup) mux.HandleFunc("POST /maintenance/backup/restore", sys.RestoreConfig) mux.HandleFunc("GET /maintenance/system", sys.SystemControl) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 7091dd3e..d0ac4406 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -1456,6 +1456,92 @@ details[open].nav-group-top > summary.nav-group-summary-top::before { monospaced log buffer with client-side text/regex filter and best-effort severity colouring (heuristic, since on-disk sysklogd doesn't store ). Live tail, rotations and download wire up in the next step. */ +/* ========================================================================== + Maintenance > Diagnostics + ========================================================================== */ +.diag-card { display: flex; flex-direction: column; } +.diag-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.diag-tab { + padding: 0.3rem 0.75rem; + border: 1px solid var(--border); + border-radius: 4px; + background: transparent; + color: var(--fg-muted); + font-size: 0.85rem; + cursor: pointer; + transition: background 0.1s, color 0.1s, border-color 0.1s; +} +.diag-tab:hover { color: var(--fg); border-color: var(--fg-muted); } +.diag-tab.active { background: var(--primary); color: #fff; border-color: var(--primary); } + +.diag-form { padding: 0.9rem 1rem 0.4rem; } +/* The [hidden] attribute is how JS shows per-tool option fields, but the + author `display:flex` on .diag-field outranks the UA [hidden] rule, so + hidden fields would still render. Restore it for everything inside the + form (specificity class+attr beats the bare class). */ +.diag-form [hidden] { display: none; } +.diag-row { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 0.75rem; } +.diag-row:empty { margin: 0; } +.diag-field { display: flex; flex-direction: column; gap: 0.25rem; } +.diag-field-grow { flex: 1 1 16rem; } +.diag-label { font-size: 0.78rem; color: var(--fg-muted); } +.diag-hint { font-size: 0.8rem; color: var(--fg-muted); align-self: center; } +.diag-opts:empty { display: none; } +.diag-count, .diag-size, .diag-maxhops, .diag-mtr-count, .diag-mtr-size { width: 7rem; } +.diag-check { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.85rem; white-space: nowrap; padding: 0.15rem 0; } +.diag-check input { margin: 0; } + +.diag-actions { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0 0.5rem; } +.diag-status { font-size: 0.78rem; color: var(--fg-muted); margin-left: 0.4rem; letter-spacing: 0.02em; } +.diag-status:not(:empty)::before { content: "●"; margin-right: 0.25rem; font-size: 0.85em; } +.diag-status.pending::before { color: var(--warning, #c97a00); animation: logs-tail-pulse 1s ease-in-out infinite; } +.diag-status.err::before { color: var(--danger, #b03030); } + +.diag-output { padding: 0.25rem 1rem 1rem; } +.diag-placeholder { color: var(--fg-muted); font-size: 0.85rem; padding: 1rem 0; } +.diag-text { + font-family: var(--font-mono); + font-size: 0.78rem; + line-height: 1.4; + background: var(--surface-deep, var(--surface)); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.5rem 0.75rem; + margin: 0; + max-height: 55vh; + overflow: auto; + white-space: pre-wrap; + word-break: break-all; +} +.diag-mtr { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} +.diag-mtr th, .diag-mtr td { + text-align: right; + padding: 0.3rem 0.6rem; + border-bottom: 1px solid var(--border); + font-variant-numeric: tabular-nums; +} +.diag-mtr th { color: var(--fg-muted); font-weight: 600; font-size: 0.76rem; } +.diag-mtr-hop { text-align: right; width: 3rem; } +.diag-mtr-host { text-align: left; font-family: var(--font-mono); } +.diag-mtr tbody tr:hover { background: var(--surface); } +.diag-loss-bad { color: var(--danger); font-weight: 600; } + +.diag-dns-head { font-size: 0.9rem; margin: 0.5rem 0; } +.diag-dns-list { list-style: none; padding: 0; margin: 0; } +.diag-dns-list li { padding: 0.2rem 0; font-family: var(--font-mono); font-size: 0.85rem; } +.diag-dns-err { color: var(--danger); font-size: 0.9rem; } + /* On the logs page, #content holds only the logs card — turn it into a flex column and clip its own overflow so the card fills the available height and the *buffer* scrolls, not the page. :has scopes this to diff --git a/src/webui/static/img/icons/README.md b/src/webui/static/img/icons/README.md index 9db3df68..4b54b184 100644 --- a/src/webui/static/img/icons/README.md +++ b/src/webui/static/img/icons/README.md @@ -17,6 +17,7 @@ untouched. | containers.svg | container | | dashboard.svg | circle-gauge | | dhcp.svg | network | +| diagnostics.svg | activity | | dns.svg | globe | | download.svg | download | | firewall.svg | shield-check | diff --git a/src/webui/static/img/icons/diagnostics.svg b/src/webui/static/img/icons/diagnostics.svg new file mode 100644 index 00000000..903be414 --- /dev/null +++ b/src/webui/static/img/icons/diagnostics.svg @@ -0,0 +1 @@ + diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index f2345582..22f9e971 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -2211,3 +2211,355 @@ function renderCfgLog() { } }); })(); + +// ─── Maintenance > Diagnostics ───────────────────────────────────────────── +// Ping / traceroute / mtr / DNS lookup. Tabs are client-side: switching +// tool only changes which option fields and which output surface show. +// Run opens an EventSource to /maintenance/diagnostics/run and renders +// streamed `line` / `hop` / `done` events; Stop closes it, which cancels +// the server request context and kills the spawned tool. DNS lookup is a +// one-shot fetch instead of a stream. +(function () { + var diagES = null; // active stream, module-scoped so afterSwap can kill it + + function diagTeardown() { + if (diagES) { + diagES.close(); + diagES = null; + } + } + + // Tools that take a network target + source interface (everything but + // DNS, which resolves a name and has no source-interface concept here). + function isNetTool(tool) { + return tool === 'ping' || tool === 'traceroute' || tool === 'mtr' || tool === 'nmap'; + } + + function setActiveTool(card, tool) { + card.dataset.active = tool; + + card.querySelectorAll('.diag-tab').forEach(function (t) { + t.classList.toggle('active', t.getAttribute('data-tool') === tool); + }); + + // Show only the option fields belonging to this tool. + card.querySelectorAll('.diag-opt').forEach(function (o) { + o.hidden = o.getAttribute('data-tool') !== tool; + }); + + // Source interface only applies to the network tools. + card.querySelectorAll('.diag-only-net').forEach(function (el) { + el.hidden = !isNetTool(tool); + }); + + // mtr's cycle/size fields only make sense when not running forever. + if (tool === 'mtr') applyMtrContinuous(card); + + // Retarget the input label/placeholder per tool. + var label = card.querySelector('[data-target-label]'); + var input = card.querySelector('.diag-target'); + if (tool === 'dns') { + if (label) label.textContent = 'Name to resolve'; + if (input) input.placeholder = 'hostname'; + } else if (tool === 'nmap') { + if (label) label.textContent = 'Target host, address, or CIDR'; + if (input) input.placeholder = 'host, IP, or 192.168.0.0/24'; + } else { + if (label) label.textContent = 'Target host or address'; + if (input) input.placeholder = 'hostname or IP'; + } + + // Reset to the ready state: kill any stream, hide all output + // surfaces, show the placeholder hint. The matching surface is + // revealed on Run. Switching tool is a context change, so dropping + // the previous tool's output is the expected "fresh start". + diagTeardown(); + card.querySelector('.diag-text').hidden = true; + card.querySelector('.diag-mtr').hidden = true; + card.querySelector('.diag-dns').hidden = true; + card.querySelector('.diag-placeholder').hidden = false; + diagSetStatus(card, '', ''); + diagSetRunning(card, false); + } + + function diagSetStatus(card, text, cls) { + var s = card.querySelector('.diag-status'); + if (!s) return; + s.textContent = text || ''; + s.className = 'diag-status' + (cls ? ' ' + cls : ''); + } + + function diagSetRunning(card, running) { + card.querySelector('.diag-run').hidden = running; + card.querySelector('.diag-stop').hidden = !running; + } + + // Return focus to the target field when a run finishes so the user can + // type the next address immediately. Guard on visibility so a run that + // completes while the tab is backgrounded doesn't yank the window + // forward (the run was started, then the user looked away). + function diagFocusTarget(card) { + var input = card.querySelector('.diag-target'); + if (input && document.visibilityState !== 'hidden') { + input.focus({ preventScroll: true }); + } + } + + // Hide mtr's Cycles/Size fields while "Run continuously" is checked. + function applyMtrContinuous(card) { + var cb = card.querySelector('.diag-mtr-continuous'); + var continuous = cb ? cb.checked : true; + card.querySelectorAll('.diag-mtr-param').forEach(function (el) { + el.hidden = continuous; + }); + } + + // ── Target history (shared across tools, persisted in localStorage) ── + var DIAG_HIST_KEY = 'infix.diag.history'; + + function diagLoadHistory() { + try { return JSON.parse(localStorage.getItem(DIAG_HIST_KEY)) || []; } + catch (_) { return []; } + } + + function diagRenderHistory() { + var dl = document.getElementById('diag-history'); + if (!dl) return; + dl.innerHTML = ''; + diagLoadHistory().forEach(function (h) { + var o = document.createElement('option'); + o.value = h; + dl.appendChild(o); + }); + } + + function diagPushHistory(target) { + if (!target) return; + var list = diagLoadHistory().filter(function (h) { return h !== target; }); + list.unshift(target); + if (list.length > 25) list = list.slice(0, 25); + try { localStorage.setItem(DIAG_HIST_KEY, JSON.stringify(list)); } catch (_) {} + diagRenderHistory(); + } + + // Upsert one mtr hop row keyed by hop index. + function diagUpsertHop(tbody, hop) { + var row = tbody.querySelector('tr[data-idx="' + hop.idx + '"]'); + if (!row) { + row = document.createElement('tr'); + row.setAttribute('data-idx', hop.idx); + row.innerHTML = + '' + + ''; + // Keep rows ordered by hop index even if events arrive out of order. + var next = null; + tbody.querySelectorAll('tr').forEach(function (r) { + if (next === null && parseInt(r.getAttribute('data-idx'), 10) > hop.idx) next = r; + }); + tbody.insertBefore(row, next); + } + var c = row.children; + c[0].textContent = hop.idx + 1; + c[1].textContent = hop.host; + c[2].textContent = hop.loss.toFixed(1); + c[2].classList.toggle('diag-loss-bad', hop.loss > 0); + c[3].textContent = hop.snt; + c[4].textContent = hop.last.toFixed(1); + c[5].textContent = hop.avg.toFixed(1); + c[6].textContent = hop.best.toFixed(1); + c[7].textContent = hop.worst.toFixed(1); + } + + function diagBuildURL(card) { + var tool = card.dataset.active; + var q = new URLSearchParams(); + q.set('tool', tool); + q.set('target', card.querySelector('.diag-target').value.trim()); + q.set('family', card.querySelector('.diag-family').value); + if (isNetTool(tool)) { + q.set('iface', card.querySelector('.diag-iface').value); + } + if (tool === 'ping') { + var count = card.querySelector('.diag-count'); + var size = card.querySelector('.diag-size'); + if (count) q.set('count', count.value); + if (size) q.set('size', size.value); + } else if (tool === 'traceroute') { + var maxhops = card.querySelector('.diag-maxhops'); + if (maxhops) q.set('maxhops', maxhops.value); + } else if (tool === 'mtr') { + // Omitting count tells the backend to run forever; only send the + // cycle/size knobs when the user has opted out of continuous mode. + var cont = card.querySelector('.diag-mtr-continuous'); + if (cont && !cont.checked) { + var mc = card.querySelector('.diag-mtr-count'); + var ms = card.querySelector('.diag-mtr-size'); + if (mc) q.set('count', mc.value); + if (ms) q.set('size', ms.value); + } + } else if (tool === 'nmap') { + var scan = card.querySelector('.diag-scan'); + if (scan) q.set('scan', scan.value); + } + return '/maintenance/diagnostics/run?' + q.toString(); + } + + function diagRunDNS(card) { + var name = card.querySelector('.diag-target').value.trim(); + if (!name) { + diagSetStatus(card, 'enter a name', 'err'); + return; + } + var dns = card.querySelector('.diag-dns'); + var family = card.querySelector('.diag-family').value; + diagSetStatus(card, 'resolving…', 'pending'); + card.querySelector('.diag-placeholder').hidden = true; + dns.hidden = false; + fetch('/maintenance/diagnostics/resolve?name=' + encodeURIComponent(name) + + '&family=' + encodeURIComponent(family), { headers: { 'HX-Request': 'true' } }) + .then(function (r) { return r.text(); }) + .then(function (html) { dns.innerHTML = html; diagSetStatus(card, '', ''); diagFocusTarget(card); }) + .catch(function () { diagSetStatus(card, 'lookup failed', 'err'); }); + } + + function diagRunStream(card) { + var tool = card.dataset.active; + var target = card.querySelector('.diag-target').value.trim(); + if (!target) { + diagSetStatus(card, 'enter a target', 'err'); + return; + } + + diagTeardown(); + card.querySelector('.diag-placeholder').hidden = true; + + var textPane = card.querySelector('.diag-text'); + var mtrTable = card.querySelector('.diag-mtr'); + var mtrBody = mtrTable.querySelector('tbody'); + if (tool === 'mtr') { + mtrBody.innerHTML = ''; + mtrTable.hidden = false; + textPane.hidden = true; + } else { + textPane.textContent = ''; + textPane.hidden = false; + mtrTable.hidden = true; + } + + diagSetStatus(card, 'running…', 'pending'); + diagSetRunning(card, true); + + diagES = new EventSource(diagBuildURL(card)); + diagES.addEventListener('line', function (evt) { + textPane.textContent += evt.data + '\n'; + textPane.scrollTop = textPane.scrollHeight; + }); + diagES.addEventListener('hop', function (evt) { + try { diagUpsertHop(mtrBody, JSON.parse(evt.data)); } catch (_) {} + }); + diagES.addEventListener('done', function () { + diagTeardown(); + diagSetStatus(card, 'done', ''); + diagSetRunning(card, false); + diagFocusTarget(card); + }); + diagES.onerror = function () { + // Transient drops auto-reconnect; only react to a terminal close. + if (diagES && diagES.readyState === EventSource.CLOSED) { + diagTeardown(); + diagSetStatus(card, 'disconnected', 'err'); + diagSetRunning(card, false); + } + }; + } + + function diagRun(card) { + var input = card.querySelector('.diag-target'); + var target = input.value.trim(); + if (target) diagPushHistory(target); + // Blur the target so Firefox dismisses the datalist popup, which it + // otherwise leaves hovering over the results after a selection. + if (input) input.blur(); + if (card.dataset.active === 'dns') { + diagRunDNS(card); + } else { + diagRunStream(card); + } + } + + function diagStop(card) { + diagTeardown(); + diagSetStatus(card, 'stopped', ''); + diagSetRunning(card, false); + diagFocusTarget(card); + } + + function diagClear(card) { + card.querySelector('.diag-text').textContent = ''; + card.querySelector('.diag-mtr tbody').innerHTML = ''; + card.querySelector('.diag-dns').innerHTML = ''; + card.querySelector('.diag-text').hidden = true; + card.querySelector('.diag-mtr').hidden = true; + card.querySelector('.diag-dns').hidden = true; + card.querySelector('.diag-placeholder').hidden = false; + diagSetStatus(card, '', ''); + } + + function initDiagnostics(scope) { + var root = scope || document; + root.querySelectorAll('.diag-card:not([data-init])').forEach(function (card) { + card.dataset.init = 'true'; + + card.querySelectorAll('.diag-tab').forEach(function (tab) { + tab.addEventListener('click', function () { + setActiveTool(card, tab.getAttribute('data-tool')); + // Focus the target field so the user can type straight away. + var input = card.querySelector('.diag-target'); + if (input) input.focus(); + }); + }); + + card.querySelector('.diag-run').addEventListener('click', function () { diagRun(card); }); + card.querySelector('.diag-stop').addEventListener('click', function () { diagStop(card); }); + card.querySelector('.diag-clear').addEventListener('click', function () { diagClear(card); }); + + var cont = card.querySelector('.diag-mtr-continuous'); + if (cont) cont.addEventListener('change', function () { applyMtrContinuous(card); }); + + // Enter in ANY form field (target, count, family, …) runs the + // active tool — Run is the natural default action. Buttons are + // excluded so Enter on Run/Stop/Clear keeps their own behavior + // and doesn't fire twice. + card.querySelector('.diag-form').addEventListener('keydown', function (e) { + if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') { + e.preventDefault(); + diagRun(card); + } + }); + + diagRenderHistory(); + setActiveTool(card, card.dataset.active || 'ping'); + + // Focus the target on load too (tab clicks already do). Guard on + // visibility so a background load doesn't yank the window forward + // under X11 WMs — same reasoning as the data-autofocus handler. + var target = card.querySelector('.diag-target'); + if (target && document.visibilityState !== 'hidden') { + target.focus({ preventScroll: true }); + } + }); + } + + document.addEventListener('DOMContentLoaded', function () { + initDiagnostics(document); + if (window.htmx) { + document.body.addEventListener('htmx:afterSwap', function (evt) { + // Navigating away from the diagnostics page removes the card; + // make sure any running stream is torn down server-side. + if (!document.querySelector('.diag-card')) diagTeardown(); + var s = (evt.detail && evt.detail.target) || document; + initDiagnostics(s); + }); + } + }); +})(); diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index 713ddd54..7f9e9505 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -291,7 +291,7 @@
+{{end}} + +{{/* DNS lookup result fragment, swapped into .diag-dns. */}} +{{define "diag-dns-result"}} +{{if .Error}} +

Lookup failed for {{.Name}}: {{.Error}}

+{{else}} +

{{.Name}} resolves to:

+
    + {{range .Addrs}}
  • {{.}}
  • {{end}} +
+{{end}} +{{end}} From 629f0aa74aa5ae179567dd8736ec568d369ff2ac Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 13 Jun 2026 17:24:10 +0200 Subject: [PATCH 42/59] webui: add support bundle to Backup & Support Rename the Maintenance > Backup & Restore page to Backup & Support and add a Support Bundle card. Download support runs the on-device `support collect` tool, which gathers configuration, logs, routing, interfaces, hardware inventory, and container and service status into a single archive; the WebUI runs as root so the collection is complete. The handler buffers the archive and only commits response headers once collection succeeds, so a mid-collection failure is a clean error rather than a truncated download. An optional password encrypts the bundle via the tool's GPG support, fed on stdin so it never reaches the process list. Collection blocks for up to a minute with no output, so the write deadline is extended past the server's 15s WriteTimeout and nginx gets a matching read timeout; the browser shows a "collecting" state and saves the blob client-side using the server's filename. Signed-off-by: Joachim Wiberg --- package/webui/webui.conf | 10 ++++ src/webui/internal/handlers/system.go | 63 ++++++++++++++++++++- src/webui/internal/server/server.go | 1 + src/webui/static/css/style.css | 9 +++ src/webui/static/js/app.js | 70 ++++++++++++++++++++++++ src/webui/templates/layouts/sidebar.html | 2 +- src/webui/templates/pages/backup.html | 21 +++++++ 7 files changed, 174 insertions(+), 2 deletions(-) diff --git a/package/webui/webui.conf b/package/webui/webui.conf index 6723b92d..ac37f626 100644 --- a/package/webui/webui.conf +++ b/package/webui/webui.conf @@ -45,6 +45,16 @@ location ~ ^/maintenance/logs/[^/]+/tail$ { include /etc/nginx/webui-proxy.conf; } +# Support bundle collection runs `support collect`, which emits nothing +# until it finishes (~50 s) and then sends the whole archive at once. +# The upstream is silent for that window, so the default 60 s +# proxy_read_timeout would 504 just as the bundle is ready. Buffering +# stays on — it's a plain file download, not a stream. +location = /maintenance/support-bundle { + proxy_read_timeout 300s; + include /etc/nginx/webui-proxy.conf; +} + # Liveness probe — nginx-only, no upstream call. Used by the watchdog # div in base.html and the reboot-overlay poller. location = /device-status { diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go index 66d2df63..4fadd8fc 100644 --- a/src/webui/internal/handlers/system.go +++ b/src/webui/internal/handlers/system.go @@ -198,7 +198,7 @@ const factoryResetSpinnerHTML = `
// Backup renders the Backup & Restore maintenance page. func (h *SystemHandler) Backup(w http.ResponseWriter, r *http.Request) { - data := newPageData(w, r, "backup", "Backup & Restore") + data := newPageData(w, r, "backup", "Backup & Support") tmplName := "backup.html" if r.Header.Get("HX-Request") == "true" { tmplName = "content" @@ -208,6 +208,67 @@ func (h *SystemHandler) Backup(w http.ResponseWriter, r *http.Request) { } } +// SupportBundle runs the on-device `support collect` tool and streams the +// resulting archive back as a download. The WebUI runs as root, so the +// collection is complete (dmesg, ethtool, etc.). An optional password +// encrypts the archive via the tool's GPG support and is fed on stdin so +// it never lands in the process list. +// +// Collection emits nothing on stdout until it finishes (~50 s), then the +// whole archive at once. We buffer it and only commit response headers +// once the tool exits successfully, so a mid-collection failure becomes a +// clean 500 rather than a truncated download. --work-dir /tmp keeps the +// transient files in tmpfs; the tool cleans up after itself. +// POST /maintenance/support-bundle +func (h *SystemHandler) SupportBundle(w http.ResponseWriter, r *http.Request) { + // Collection blocks ~50 s with no output, but the server's 15 s + // WriteTimeout would close the connection long before then (nginx + // then logs a 502 "upstream prematurely closed connection"). Push + // the write deadline out for this long-running download. + if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(4 * time.Minute)); err != nil { + log.Printf("support bundle: extend write deadline: %v", err) + } + + password := r.FormValue("password") + encrypt := password != "" + + args := []string{"--work-dir", "/tmp", "collect"} + ext, ctype := "tar.gz", "application/gzip" + if encrypt { + args = append(args, "-p") + ext, ctype = "tar.gz.gpg", "application/pgp-encrypted" + } + + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) + defer cancel() + + cmd := exec.CommandContext(ctx, "/usr/sbin/support", args...) + if encrypt { + cmd.Stdin = strings.NewReader(password + "\n") + } + out, err := cmd.Output() + if err != nil { + stderr := "" + if ee, ok := err.(*exec.ExitError); ok { + stderr = strings.TrimSpace(string(ee.Stderr)) + } + log.Printf("support bundle: %v: %s", err, stderr) + http.Error(w, "Failed to collect support bundle", http.StatusInternalServerError) + return + } + + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "device" + } + fname := fmt.Sprintf("support-%s-%s.%s", hostname, time.Now().UTC().Format("20060102-1504"), ext) + + w.Header().Set("Content-Type", ctype) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fname)) + w.Header().Set("Content-Length", fmt.Sprint(len(out))) + w.Write(out) //nolint:errcheck +} + // RestoreConfig accepts a multipart-uploaded JSON config file and applies it. // target="running" (default): PUT to running so changes take effect immediately; // sets the cfg-unsaved cookie so the persistent notification prompts a save. diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 9036ef6a..766de368 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -320,6 +320,7 @@ func New( mux.HandleFunc("GET /maintenance/diagnostics/resolve", diag.Resolve) mux.HandleFunc("GET /maintenance/backup", sys.Backup) mux.HandleFunc("POST /maintenance/backup/restore", sys.RestoreConfig) + mux.HandleFunc("POST /maintenance/support-bundle", sys.SupportBundle) mux.HandleFunc("GET /maintenance/system", sys.SystemControl) mux.HandleFunc("POST /maintenance/system/reboot", sys.Reboot) mux.HandleFunc("POST /maintenance/system/shutdown", sys.Shutdown) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index d0ac4406..a5a2b661 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -1504,6 +1504,15 @@ details[open].nav-group-top > summary.nav-group-summary-top::before { .diag-status.pending::before { color: var(--warning, #c97a00); animation: logs-tail-pulse 1s ease-in-out infinite; } .diag-status.err::before { color: var(--danger, #b03030); } +/* Support bundle status pill — shares the diag-status visual language, + but sits on its own line below the button. */ +.support-status { display: block; font-size: 0.8rem; color: var(--fg-muted); margin: 0.5rem 0 0; } +.support-status:empty { margin: 0; } +.support-status:not(:empty)::before { content: "●"; margin-right: 0.25rem; font-size: 0.85em; } +.support-status.pending::before { color: var(--warning, #c97a00); animation: logs-tail-pulse 1s ease-in-out infinite; } +.support-status.err::before { color: var(--danger, #b03030); } +.sc-hint { font-size: 0.8rem; opacity: 0.8; margin-top: 0.4rem; } + .diag-output { padding: 0.25rem 1rem 1rem; } .diag-placeholder { color: var(--fg-muted); font-size: 0.85rem; padding: 1rem 0; } .diag-text { diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 22f9e971..21c1652d 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -2563,3 +2563,73 @@ function renderCfgLog() { } }); })(); + +// ─── Maintenance > Backup & Support: support bundle download ──────────────── +// `support collect` takes up to a minute and returns the whole archive at +// once, so a plain would just hang with no feedback. Instead +// fetch the bundle as a blob with a visible "generating…" state, then save +// it client-side using the filename the server set in Content-Disposition. +(function () { + function filenameFromDisposition(cd, fallback) { + var m = /filename="?([^";]+)"?/.exec(cd || ''); + return m ? m[1] : fallback; + } + + function initSupportBundle(scope) { + (scope || document).querySelectorAll('.support-generate:not([data-init])').forEach(function (btn) { + btn.dataset.init = 'true'; + var card = btn.closest('.info-card'); + var pass = card.querySelector('.support-pass'); + var status = card.querySelector('.support-status'); + + function setStatus(text, cls) { + if (!status) return; + status.textContent = text || ''; + status.className = 'support-status' + (cls ? ' ' + cls : ''); + } + + btn.addEventListener('click', function () { + btn.disabled = true; + setStatus('Collecting… (up to a minute)', 'pending'); + + var body = new FormData(); + if (pass && pass.value) body.append('password', pass.value); + + fetch('/maintenance/support-bundle', { + method: 'POST', + headers: { 'X-CSRF-Token': btn.getAttribute('data-csrf') || '' }, + body: body + }).then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + var name = filenameFromDisposition(r.headers.get('Content-Disposition'), 'support-bundle.tar.gz'); + return r.blob().then(function (blob) { return { blob: blob, name: name }; }); + }).then(function (res) { + var url = URL.createObjectURL(res.blob); + var a = document.createElement('a'); + a.href = url; + a.download = res.name; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function () { URL.revokeObjectURL(url); }, 10000); + setStatus('Downloaded ' + res.name, ''); + if (pass) pass.value = ''; + }).catch(function (e) { + setStatus('Failed to generate bundle', 'err'); + if (window.console) console.warn('[support]', e); + }).finally(function () { + btn.disabled = false; + }); + }); + }); + } + + document.addEventListener('DOMContentLoaded', function () { + initSupportBundle(document); + if (window.htmx) { + document.body.addEventListener('htmx:afterSwap', function (evt) { + initSupportBundle((evt.detail && evt.detail.target) || document); + }); + } + }); +})(); diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index 7f9e9505..5939d4e9 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -331,7 +331,7 @@ hx-push-url="true" class="nav-link {{if eq .ActivePage "backup"}}active{{end}}"> - Backup & Restore + Backup & Support
  • diff --git a/src/webui/templates/pages/backup.html b/src/webui/templates/pages/backup.html index 8b120165..b1da3ad2 100644 --- a/src/webui/templates/pages/backup.html +++ b/src/webui/templates/pages/backup.html @@ -44,5 +44,26 @@
  • +
    +
    Support Bundle
    +
    +

    Collect system state — configuration, logs, routing, + interfaces, hardware inventory, and container and service status — into a + single archive to attach to a support ticket. Collection can take up to + a minute.

    +
    + +
    +

    Leave blank for a plain .tar.gz, or set + a password to encrypt the bundle (GPG) before download.

    +
    + +
    +

    +
    +
    +
    {{end}} From 8b3ba1f5c5e7f779f5dc558afcb7f563b61fb838 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 13 Jun 2026 17:59:07 +0200 Subject: [PATCH 43/59] webui: add web console shortcut to topbar and nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the ttyd web console (port 7681) from the WebUI: a terminal icon in the topbar and a Console entry under Maintenance, both opening the console in a new tab. The link target is built client-side from the current hostname since the console runs on its own HTTPS port. The console is a config-gated capability (infix-services web/console enabled), so both entries are detected at login and rendered only when it is enabled — a disabled console shows no entry rather than a dead link. Detection fails closed: a config read error hides the entry. Signed-off-by: Joachim Wiberg --- src/webui/internal/auth/login.go | 3 +++ src/webui/internal/handlers/capabilities.go | 22 +++++++++++++++++++++ src/webui/static/css/style.css | 16 +++++++++++++++ src/webui/static/img/icons/README.md | 1 + src/webui/static/img/icons/console.svg | 1 + src/webui/static/js/app.js | 14 +++++++++++++ src/webui/templates/layouts/base.html | 8 ++++++++ src/webui/templates/layouts/icons.html | 1 + src/webui/templates/layouts/sidebar.html | 10 ++++++++++ 9 files changed, 76 insertions(+) create mode 100644 src/webui/static/img/icons/console.svg diff --git a/src/webui/internal/auth/login.go b/src/webui/internal/auth/login.go index 1eda5576..7257bedb 100644 --- a/src/webui/internal/auth/login.go +++ b/src/webui/internal/auth/login.go @@ -74,6 +74,9 @@ func (h *LoginHandler) DoLogin(w http.ResponseWriter, r *http.Request) { // Probe optional features once at login and bake into the session. caps := handlers.DetectCapabilities(ctx, h.RC) + // The web console (ttyd) is config-gated; fold it into the same feature + // map so templates gate on .Capabilities.Has "console". + caps.Features()["console"] = handlers.DetectConsole(ctx, h.RC) // Trigger any post-login hooks (e.g. schema sync) with full credentials. if h.OnLogin != nil { diff --git a/src/webui/internal/handlers/capabilities.go b/src/webui/internal/handlers/capabilities.go index be64edf9..87485aac 100644 --- a/src/webui/internal/handlers/capabilities.go +++ b/src/webui/internal/handlers/capabilities.go @@ -70,6 +70,28 @@ type yangLibrary struct { } `json:"ietf-yang-library:yang-library"` } +// webConsoleConfig mirrors the slice of infix-services we need for the +// console gate. +type webConsoleConfig struct { + Web struct { + Console struct { + Enabled bool `json:"enabled"` + } `json:"console"` + } `json:"infix-services:web"` +} + +// DetectConsole reports whether the web console (ttyd on :7681) is enabled +// in config, so the UI can show or hide the terminal entry. Fails closed: +// a read error hides the entry rather than offering a dead link. +func DetectConsole(ctx context.Context, rc restconf.Fetcher) bool { + var cfg webConsoleConfig + if err := rc.Get(ctx, "/data/infix-services:web", &cfg); err != nil { + log.Printf("web/console config: %v (console entry hidden)", err) + return false + } + return cfg.Web.Console.Enabled +} + 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 { diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index a5a2b661..42a63109 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -2133,6 +2133,22 @@ details[open] > .cfg-multi-summary { border-color: var(--fg-muted); } +/* Web console (terminal) shortcut in the topbar */ +.topbar-console { + display: flex; + align-items: center; + justify-content: center; + padding: 0.35rem; + border-radius: var(--radius); + color: var(--fg-muted); + text-decoration: none; + transition: color 0.15s, background 0.15s; +} +.topbar-console:hover { + color: var(--fg); + background: var(--border); +} + /* User menu button */ .user-menu { position: relative; diff --git a/src/webui/static/img/icons/README.md b/src/webui/static/img/icons/README.md index 4b54b184..7f137fd7 100644 --- a/src/webui/static/img/icons/README.md +++ b/src/webui/static/img/icons/README.md @@ -14,6 +14,7 @@ untouched. |-------------------|-----------------------| | advanced.svg | folder-git-2 | | backup.svg | archive-restore | +| console.svg | square-terminal | | containers.svg | container | | dashboard.svg | circle-gauge | | dhcp.svg | network | diff --git a/src/webui/static/img/icons/console.svg b/src/webui/static/img/icons/console.svg new file mode 100644 index 00000000..8454b87c --- /dev/null +++ b/src/webui/static/img/icons/console.svg @@ -0,0 +1 @@ + diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 21c1652d..288a1f75 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -2633,3 +2633,17 @@ function renderCfgLog() { } }); })(); + +// ─── Web console (ttyd) link ──────────────────────────────────────────────── +// The console runs on its own HTTPS port (7681), so the link target can't be +// a relative path — build it from the current hostname at load time. CSP +// (script-src 'self') forbids an inline onclick, hence this lives here. The +// topbar/sidebar only render on full page loads, so DOMContentLoaded covers it. +(function () { + document.addEventListener('DOMContentLoaded', function () { + var links = document.querySelectorAll('[data-console-link]'); + if (!links.length) return; + var url = 'https://' + window.location.hostname + ':7681/'; + links.forEach(function (a) { a.href = url; }); + }); +})(); diff --git a/src/webui/templates/layouts/base.html b/src/webui/templates/layouts/base.html index 5cb7a47e..8e53cb38 100644 --- a/src/webui/templates/layouts/base.html +++ b/src/webui/templates/layouts/base.html @@ -24,6 +24,14 @@
    + {{if .Capabilities.Has "console"}} + {{/* Web console (ttyd) on :7681. href is set by JS from the current + hostname since the port differs and CSP forbids inline script. */}} + + {{template "icon-terminal"}} + + {{end}}
    + {{if .Capabilities.Has "netbrowse"}} + {{/* mDNS network browser — fixed network.local vhost, so a static + href (no JS, no CSP concern). */}} + + {{template "icon-radar"}} + + {{end}} {{if .Capabilities.Has "console"}} {{/* Web console (ttyd) on :7681. href is set by JS from the current hostname since the port differs and CSP forbids inline script. */}} - {{template "icon-terminal"}} diff --git a/src/webui/templates/layouts/icons.html b/src/webui/templates/layouts/icons.html index 9c5d236d..95d472f8 100644 --- a/src/webui/templates/layouts/icons.html +++ b/src/webui/templates/layouts/icons.html @@ -11,6 +11,7 @@ {{define "icon-reset"}}{{end}} {{define "icon-menu"}}{{end}} {{define "icon-terminal"}}{{end}} +{{define "icon-radar"}}{{end}} {{define "icon-user"}}{{end}} {{define "icon-user-lg"}}{{end}} {{define "icon-chevron-down"}}{{end}} diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index f6de73cf..eb3a812c 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -103,6 +103,16 @@ mDNS + {{if .Capabilities.Has "netbrowse"}} + {{/* External link to the mDNS network browser at network.local + (new tab); not an htmx nav. */}} +
  • + + + Network Browser + +
  • + {{end}}
  • Date: Sat, 13 Jun 2026 19:07:32 +0200 Subject: [PATCH 45/59] webui: bundle on-device User's Guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the mkdocs User's Guide into WebUI images and surface it in the UI. post-build.sh runs `mkdocs build` from the top-level mkdocs.yml into /var/www/guide when the webui package is selected and mkdocs is on the build host; nginx serves it read-only at /guide/. The build is best-effort — a missing mkdocs warns and ships without the guide rather than failing — and minimal images (no webui) skip it. The WebUI gates a book icon in the topbar and a User Guide item in the user menu on the docs being present on disk (os.Stat, fail-closed), both opening /guide/ in a new tab. CI: a local setup-mkdocs composite action installs mkdocs and the plugins from mkdocs.yml; the build and release workflows run it before the build so images produced in CI include the guide. developers-guide documents the new build dependency and restores the missing mkdocs-glightbox plugin. Fixes #633 Signed-off-by: Joachim Wiberg --- .github/actions/setup-mkdocs/action.yml | 20 ++++++++++++++++ .github/workflows/build-release.yml | 3 +++ .github/workflows/build.yml | 3 +++ board/common/post-build.sh | 26 +++++++++++++++++++++ doc/developers-guide.md | 18 ++++++++++---- package/webui/webui.conf | 9 +++++++ src/webui/internal/auth/login.go | 3 +++ src/webui/internal/handlers/capabilities.go | 12 ++++++++++ src/webui/templates/layouts/base.html | 14 +++++++++++ src/webui/templates/layouts/icons.html | 1 + 10 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 .github/actions/setup-mkdocs/action.yml diff --git a/.github/actions/setup-mkdocs/action.yml b/.github/actions/setup-mkdocs/action.yml new file mode 100644 index 00000000..69a5568f --- /dev/null +++ b/.github/actions/setup-mkdocs/action.yml @@ -0,0 +1,20 @@ +name: 'Setup MkDocs' +description: "Install MkDocs and the plugins required to build the Infix User's Guide (post-build.sh bundles it into WebUI images)." + +# Mirrors the dependency set in .github/workflows/docs.yml. Kept in-repo +# (rather than an external action) so any checkout of a commit needing +# docs-in-image can reproduce it without an out-of-tree dependency. +runs: + using: composite + steps: + - name: Install MkDocs and plugins + shell: bash + run: | + command -v pipx >/dev/null 2>&1 || python3 -m pip install --user pipx + python3 -m pipx install --force mkdocs + python3 -m pipx inject mkdocs \ + mkdocs-material pymdown-extensions mkdocs-callouts mike mkdocs-to-pdf mkdocs-glightbox + # Make the pipx-installed mkdocs visible to later steps (the build, + # where post-build.sh invokes it). + python3 -m pipx environment --value PIPX_BIN_DIR >> "$GITHUB_PATH" 2>/dev/null \ + || echo "$HOME/.local/bin" >> "$GITHUB_PATH" diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 2e600039..059ef3e7 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -45,6 +45,9 @@ jobs: target: ${{ matrix.target }} enabled: ${{ inputs.use_cache }} + # WebUI images bundle the mkdocs User's Guide via post-build.sh. + - uses: ./.github/actions/setup-mkdocs + - name: Configure & Build env: INFIX_RELEASE: ${{ steps.vars.outputs.ver }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 47cc271d..afc9e145 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -124,6 +124,9 @@ jobs: with: target: ${{ env.TARGET }} + # WebUI images bundle the mkdocs User's Guide via post-build.sh. + - uses: ./.github/actions/setup-mkdocs + - name: Configure ${{ env.TARGET }} run: | make ${{ env.TARGET }}_defconfig diff --git a/board/common/post-build.sh b/board/common/post-build.sh index 2f9e665e..305f77ad 100755 --- a/board/common/post-build.sh +++ b/board/common/post-build.sh @@ -120,3 +120,29 @@ grep -qsE '^/bin/clish$$' "$TARGET_DIR/etc/shells" \ if [ "$BR2_PACKAGE_HOST_PYTHON_YANGDOC" = "y" ]; then mkyangdoc "$BINARIES_DIR/yangdoc.html" fi + +# Bundle the mkdocs User's Guide into the rootfs, served by the WebUI's +# nginx at /guide/. Only when the WebUI is present (nothing serves it +# otherwise, and it keeps minimal images small) and mkdocs is on the build +# host. Best-effort: a failed build warns but does not abort the image +# build, and the WebUI hides its User Guide entry when the docs are absent. +mkuserguide() +{ + local cfg dst + cfg="$(readlink -f "$common/../..")/mkdocs.yml" + dst="$TARGET_DIR/var/www/guide" + + if ! command -v mkdocs >/dev/null 2>&1; then + ixmsg "mkdocs not found, skipping User's Guide bundling" + return + fi + ixmsg "Building User's Guide into $dst" + if ! mkdocs build -f "$cfg" -d "$dst" --clean --quiet; then + ixmsg "WARNING: mkdocs build failed, shipping without on-device User's Guide" + rm -rf "$dst" + fi +} + +if [ "$BR2_PACKAGE_WEBUI" = "y" ]; then + mkuserguide +fi diff --git a/doc/developers-guide.md b/doc/developers-guide.md index 032fc69d..b1cdd2e4 100644 --- a/doc/developers-guide.md +++ b/doc/developers-guide.md @@ -122,13 +122,21 @@ recommend using `pipx` to install the necessary tooling: ```bash $ sudo apt install pipx $ pipx install mkdocs -$ pipx inject mkdocs mkdocs-material pymdown-extensions mkdocs-callouts mike mkdocs-to-pdf +$ pipx inject mkdocs mkdocs-material pymdown-extensions mkdocs-callouts mike mkdocs-to-pdf mkdocs-glightbox ``` -The last two packages, `mike` and `mkdocs-to-pdf`, are used for online -versioning and PDF generation by GitHub Actions, but since they are in -the `mkdocs.yml` file, everyone who wants to preview the documentation -have to install all the tooling. +The `mike` and `mkdocs-to-pdf` packages are used for online versioning +and PDF generation by GitHub Actions, but since every plugin is listed +in `mkdocs.yml`, anyone who wants to preview the documentation has to +install all the tooling. + +> [!IMPORTANT] +> MkDocs is also required to **build a WebUI image**. The build bundles +> the User's Guide into the image (served on-device at `/guide/`), so +> `make` runs `mkdocs build` from `post-build.sh` when the `webui` +> package is selected. If MkDocs is missing the build still succeeds, +> but the image ships without the on-device guide. Minimal images and +> any build without the `webui` package skip this step entirely. Preview with: diff --git a/package/webui/webui.conf b/package/webui/webui.conf index ac37f626..62c27838 100644 --- a/package/webui/webui.conf +++ b/package/webui/webui.conf @@ -55,6 +55,15 @@ location = /maintenance/support-bundle { include /etc/nginx/webui-proxy.conf; } +# On-device User's Guide: the mkdocs site bundled into the rootfs at +# /var/www/guide by post-build.sh. Served as static files (no upstream +# call), public like the published guide. Present only when the build +# host had mkdocs; the WebUI hides its User Guide entry when it's absent. +location /guide/ { + alias /var/www/guide/; + index index.html; +} + # Liveness probe — nginx-only, no upstream call. Used by the watchdog # div in base.html and the reboot-overlay poller. location = /device-status { diff --git a/src/webui/internal/auth/login.go b/src/webui/internal/auth/login.go index 8274814a..733445a4 100644 --- a/src/webui/internal/auth/login.go +++ b/src/webui/internal/auth/login.go @@ -80,6 +80,9 @@ func (h *LoginHandler) DoLogin(w http.ResponseWriter, r *http.Request) { console, netbrowse := handlers.DetectWebShortcuts(ctx, h.RC) caps.Features()["console"] = console caps.Features()["netbrowse"] = netbrowse + // 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() // Trigger any post-login hooks (e.g. schema sync) with full credentials. if h.OnLogin != nil { diff --git a/src/webui/internal/handlers/capabilities.go b/src/webui/internal/handlers/capabilities.go index 21cd497c..403df1cc 100644 --- a/src/webui/internal/handlers/capabilities.go +++ b/src/webui/internal/handlers/capabilities.go @@ -5,10 +5,22 @@ package handlers import ( "context" "log" + "os" "infix/webui/internal/restconf" ) +// docsIndexPath is the entry point of the on-device User's Guide bundled +// by post-build.sh (mkdocs → /var/www/guide), served by nginx at /guide/. +const docsIndexPath = "/var/www/guide/index.html" + +// DetectDocs reports whether the User's Guide was bundled into this image +// (the build host had mkdocs), so the UI can show or hide the entry. +func DetectDocs() bool { + _, err := os.Stat(docsIndexPath) + return err == nil +} + type feature struct { Name string // key used in Has() and session cookie Module string // YANG module that carries the feature diff --git a/src/webui/templates/layouts/base.html b/src/webui/templates/layouts/base.html index a3bfa4f4..2f960a9c 100644 --- a/src/webui/templates/layouts/base.html +++ b/src/webui/templates/layouts/base.html @@ -24,6 +24,13 @@
    + {{if .Capabilities.Has "docs"}} + {{/* On-device User's Guide (static mkdocs site at /guide/). */}} + + {{template "icon-book"}} + + {{end}} {{if .Capabilities.Has "netbrowse"}} {{/* mDNS network browser — fixed network.local vhost, so a static href (no JS, no CSP concern). */}} @@ -85,6 +92,13 @@ Off {{template "icon-check"}} + {{if .Capabilities.Has "docs"}} + + + {{template "icon-book"}} + User Guide + + {{end}}
    diff --git a/src/webui/templates/layouts/icons.html b/src/webui/templates/layouts/icons.html index 95d472f8..935741a1 100644 --- a/src/webui/templates/layouts/icons.html +++ b/src/webui/templates/layouts/icons.html @@ -12,6 +12,7 @@ {{define "icon-menu"}}{{end}} {{define "icon-terminal"}}{{end}} {{define "icon-radar"}}{{end}} +{{define "icon-book"}}{{end}} {{define "icon-user"}}{{end}} {{define "icon-user-lg"}}{{end}} {{define "icon-chevron-down"}}{{end}} From f87505670676bc8d3014231726033b1daa081c38 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 14 Jun 2026 09:08:22 +0200 Subject: [PATCH 46/59] webui: reload page on reconnect -> /login When we reconnect to a device we should reload the page instead of silently resume. Because the device may have been rebooted from the CLI or experienced power loss. Either way, our session in RAM only is likely gone, and if so we'll be redirected to /login again. Signed-off-by: Joachim Wiberg --- src/webui/static/js/app.js | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 288a1f75..f740d419 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -68,14 +68,25 @@ tickerId = setInterval(tick, 1000); } - function hide() { - banner.hidden = true; - banner.textContent = 'Device unreachable'; - firstFailureMs = null; + // Reconnect: the device is answering again after a disconnect. It may + // have rebooted or been upgraded out of band, in which case + // running-config reverted to startup (activated-but-unsaved changes + // are gone), operational data is stale, and the in-memory session may + // no longer exist. So we do NOT resume the page the user was on — + // we reload it. A live session re-fetches true post-reboot state; a + // dead one makes the server 303-redirect the navigation to /login. + // The watchdog only fires `show()` after a >=5 s outage, so this never + // triggers on a sub-second blip — only on outages long enough to + // plausibly be a reboot. + function reconnect() { + if (firstFailureMs === null) return; // we were never down + banner.hidden = false; + banner.textContent = 'Device back online — reloading…'; if (tickerId !== null) { clearInterval(tickerId); tickerId = null; } + window.location.reload(); } document.addEventListener('htmx:configRequest', function (evt) { @@ -93,12 +104,9 @@ if (!evt.detail) return; var xhr = evt.detail.xhr; var status = xhr ? xhr.status : 0; - // Any HTTP response < 500 means the server is reachable — hide. - // (Pre-fix the banner stuck on after the next poll returned 404 - // because the old check used `successful`, which is true only for - // 2xx.) status === 0 = no response, leave the banner alone; - // sendError / timeout handles it. - if (status > 0 && status < 500) hide(); + // Any HTTP response < 500 means the server is reachable again. + // status === 0 = no response; leave it to sendError / timeout. + if (status > 0 && status < 500) reconnect(); }); } From b5b310f3c8f9fa103dcbf6831ad791cb3c4d9fb2 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 14 Jun 2026 10:34:45 +0200 Subject: [PATCH 47/59] webui: fix enabling DHCP/L3 on an interface with no prior IPv4/IPv6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SaveIPv4Settings/SaveIPv6Settings PATCHed the ietf-ip:ipv4 container to set forwarding, but a plain PATCH 400s ("Target resource does not exist") when the interface has no L3 config yet — exactly the first-time DHCP-enable case. PATCH the interface instead, with the family container nested in a single-element list entry (rousette rejects the bare-object form with LY_EVALID once an augmented container is nested), so the container is created on first use. Signed-off-by: Joachim Wiberg --- .../internal/handlers/configure_interfaces.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/webui/internal/handlers/configure_interfaces.go b/src/webui/internal/handlers/configure_interfaces.go index e9ceb900..b59fd1cb 100644 --- a/src/webui/internal/handlers/configure_interfaces.go +++ b/src/webui/internal/handlers/configure_interfaces.go @@ -2180,8 +2180,23 @@ func (h *ConfigureInterfacesHandler) saveIPSettings(w http.ResponseWriter, r *ht base := ifacePath(name) + "/" + container forwarding := r.FormValue("forwarding") == "true" - body := map[string]any{container: map[string]any{"forwarding": forwarding}} - if err := h.RC.Patch(r.Context(), base, body); err != nil { + // PATCH the interface (which always exists) with the family container + // nested inside, so the container is created on first use. PATCHing + // `base` (the ipv4/ipv6 container) directly 400s "Target resource does + // not exist" on a fresh interface that has no L3 config yet — which is + // exactly the case when enabling DHCP for the first time. The list + // entry must be a single-element array: rousette rejects the bare-object + // form with LY_EVALID once an augmented container (ietf-ip:ipv4) is + // nested inside. + body := map[string]any{ + "ietf-interfaces:interface": []any{ + map[string]any{ + "name": name, + container: map[string]any{"forwarding": forwarding}, + }, + }, + } + if err := h.RC.Patch(r.Context(), ifacePath(name), body); err != nil { log.Printf("configure interfaces %s %s settings: forwarding: %v", name, family, err) renderSaveError(w, err) return From cd19b626a76e3694c66488cac0adad1aa93e750f Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 14 Jun 2026 10:35:00 +0200 Subject: [PATCH 48/59] webui: surface confd inference in the advanced tree editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit confd infers Infix-suggested values (DHCP option lists, related leaves, normalised values) into the candidate when a change is written, but the tree editor's write handlers only wrote — SaveLeaf echoed the typed value back, SaveGroup returned 204 — so the inferred values never appeared until a manual page reload. After a save (cfgSaved) re-fetch the current tree page from the now up-to-date candidate and re-render it, so inference surfaces wherever it landed on the page. SaveLeaf now emits cfgSaved + 204 like SaveGroup (its form uses hx-swap="none") instead of echoing the raw input. Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/yang_tree.go | 16 ++++++++-------- src/webui/static/js/app.js | 16 ++++++++++++++++ .../templates/fragments/yang-node-detail.html | 6 ++++-- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/webui/internal/handlers/yang_tree.go b/src/webui/internal/handlers/yang_tree.go index 1f01ea8e..e5fe380f 100644 --- a/src/webui/internal/handlers/yang_tree.go +++ b/src/webui/internal/handlers/yang_tree.go @@ -1227,17 +1227,17 @@ func (h *TreeHandler) SaveLeaf(w http.ResponseWriter, r *http.Request) { } body := map[string]any{qualName: coerceLeafValue(rawValue, node)} - data := &nodeDetailData{Node: node} + // Emit cfgSaved/cfgError like SaveGroup and let the client re-fetch the + // whole current page from the fresh candidate (so confd inference and + // normalisation surface) — don't echo the raw input back, which hid it. if putErr := h.RC.Put(r.Context(), candidateDS+path, body); putErr != nil { - data.Error = putErr.Error() - data.CurrentValue = rawValue - } else { - data.SavedOK = true - data.CurrentValue = rawValue + w.Header().Set("HX-Trigger", `{"cfgError":"`+node.Name+": "+putErr.Error()+`"}`) + w.WriteHeader(http.StatusUnprocessableEntity) + return } - - h.FragTmpl.ExecuteTemplate(w, "yang-node-detail", data) + w.Header().Set("HX-Trigger", `{"cfgSaved":"Saved `+node.Name+` to candidate"}`) + w.WriteHeader(http.StatusNoContent) } // DeleteLeaf serves DELETE /configure/tree/node?path=... diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index f740d419..19d9425e 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -2121,6 +2121,22 @@ function renderCfgLog() { span.classList.remove('saved'); }, 3000); } + + // Re-render the whole current tree page from the (now fresh) candidate. + // The save handlers only WRITE — they don't echo back what confd + // inferred from the change (DHCP option lists, related leaves, + // normalised values), and inference can land outside the edited node. + // Re-fetching the page the user is on surfaces all of it. Guarded on + // #yang-detail so curated pages that also emit cfgSaved are unaffected. + var detail = document.getElementById('yang-detail'); + if (detail && window.htmx) { + var node = (history.state && history.state.yangDetailPath) || + new URLSearchParams(window.location.search).get('node'); + if (node) { + htmx.ajax('GET', '/configure/tree/node?path=' + encodeURIComponent(node), + { target: '#yang-detail', swap: 'innerHTML' }); + } + } }); })(); diff --git a/src/webui/templates/fragments/yang-node-detail.html b/src/webui/templates/fragments/yang-node-detail.html index a5170538..3238f917 100644 --- a/src/webui/templates/fragments/yang-node-detail.html +++ b/src/webui/templates/fragments/yang-node-detail.html @@ -21,10 +21,12 @@ {{/* ── Edit form — only for writable leaves in non-ReadOnly mode ── */}} {{if and (not .ReadOnly) .Config (or (eq .Kind "leaf") (eq .Kind "leaf-list"))}} + {{/* hx-swap="none": SaveLeaf returns 204 + a cfgSaved trigger, and the + cfgSaved handler re-fetches the whole current tree page from the + fresh candidate so confd-inferred values surface. */}} + hx-swap="none"> {{end}}{{/* HasIP */}} @@ -1839,3 +1591,264 @@ {{end}} + + +{{define "iface-ipv4-block"}} +
    + {{$ipv4Form := printf "ipv4-form-%s" $.Name}} + {{$ipv4Fold := printf "ipv4-dhcp-%s" $.Name}} +
    IPv4{{template "field-info" (index $.Desc "ipv4-address")}}
    +
    +
    + + + +
    +
    + + {{/* DHCPv4 foldout — always in the DOM, hidden when DHCP is off. + The data-fold-target hook on the DHCP checkbox toggles + this without waiting for a server round-trip so the + foldout is discoverable before clicking Save. */}} +
    + DHCP Settings & Options +
    +
    + + + + + + + + + + + + + +
    Client ID
    ARP check
    Route preference
    + +
    +
    DHCP Options
    + {{if .IPv4.DHCP.Options}} + + + + {{range .IPv4.DHCP.Options}} + + + + + + {{end}} + +
    OptionValue
    {{.ID}}{{if .Value}}{{.Value}}{{else if .Hex}}hex:{{.Hex}}{{else}}{{end}} + +
    + {{end}} + {{if $.DHCPv4Options}} +
    + + + + +
    + {{end}} +
    +
    + + {{if and .IPv4 .IPv4.Address}} + + {{range .IPv4.Address}} + + + + + + {{end}} +
    {{.IP}}/{{.PrefixLength}}{{if .Origin}}{{.Origin}}{{end}} + {{if or (eq .Origin "") (eq .Origin "static")}} + + {{end}} +
    + {{else}} +

    No IPv4 addresses configured.

    + {{end}} +
    + + / + + + +
    + + +
    +{{end}} + +{{define "iface-ipv6-block"}} +
    + {{$ipv6Form := printf "ipv6-form-%s" $.Name}} + {{$ipv6Fold := printf "ipv6-dhcp-%s" $.Name}} +
    IPv6{{template "field-info" (index $.Desc "ipv6-address")}}
    +
    +
    + + + +
    +
    + +
    + DHCPv6 Settings & Options +
    +
    + + + + + + + + + + + + + +
    DUID
    Information only
    Route preference
    + +
    +
    DHCPv6 Options
    + {{if .IPv6.DHCPv6.Options}} + + + + {{range .IPv6.DHCPv6.Options}} + + + + + + {{end}} + +
    OptionValue
    {{.ID}}{{if .Value}}{{.Value}}{{else if .Hex}}hex:{{.Hex}}{{else}}{{end}} + +
    + {{end}} + {{if $.DHCPv6Options}} +
    + + + + +
    + {{end}} +
    +
    + + {{if and .IPv6 .IPv6.Address}} + + {{range .IPv6.Address}} + + + + + + {{end}} +
    {{.IP}}/{{.PrefixLength}}{{if .Origin}}{{.Origin}}{{end}} + {{if or (eq .Origin "") (eq .Origin "static")}} + + {{end}} +
    + {{else}} +

    No IPv6 addresses configured.

    + {{end}} +
    + + / + + + +
    + + +
    +{{end}} \ No newline at end of file From a78eeee65119cfeaf0a5492bfd75b1449e052fc5 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 14 Jun 2026 15:18:10 +0200 Subject: [PATCH 50/59] webui: rebuild NTP & DNS client pages around per-item editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single bulk-PUT form + client-side row cloning (which broke when these pages were split out of System settings) with the server-driven list pattern used by Keystore/Users: each server is a row keyed by name that expands to edit, with per-row delete and a dedicated add form. DNS search domains become add/delete rows. Server creates its container on first use, and field-info ⓘ help is sourced from the YANG descriptions. Signed-off-by: Joachim Wiberg --- .../internal/handlers/configure_system.go | 326 ++++++++++++------ src/webui/internal/server/server.go | 10 +- src/webui/templates/pages/configure-dns.html | 178 ++++++---- src/webui/templates/pages/configure-ntp.html | 147 +++++--- 4 files changed, 449 insertions(+), 212 deletions(-) diff --git a/src/webui/internal/handlers/configure_system.go b/src/webui/internal/handlers/configure_system.go index 719240e1..6e1d0f62 100644 --- a/src/webui/internal/handlers/configure_system.go +++ b/src/webui/internal/handlers/configure_system.go @@ -4,9 +4,11 @@ package handlers import ( "encoding/json" + "fmt" "html/template" "log" "net/http" + "net/url" "strconv" "strings" "sync" @@ -42,9 +44,11 @@ type cfgNTPJSON struct { } type cfgNTPServerJSON struct { - Name string `json:"name"` - UDP cfgNTPUDPJSON `json:"udp"` - Prefer bool `json:"prefer"` + Name string `json:"name"` + UDP cfgNTPUDPJSON `json:"udp"` + AssociationType string `json:"association-type,omitempty"` + IBurst bool `json:"iburst,omitempty"` + Prefer bool `json:"prefer,omitempty"` } type cfgNTPUDPJSON struct { @@ -89,14 +93,17 @@ type cfgSystemPageData struct { type cfgNTPPageData struct { PageData - Error string - NTP cfgNTPJSON + Error string + NTP cfgNTPJSON + AssocTypes []string // association-type enum: server / pool / peer + Desc map[string]string // leaf name → YANG description (field-info ⓘ) } type cfgDNSPageData struct { PageData Error string DNS cfgDNSJSON + Desc map[string]string // leaf name → YANG description (field-info ⓘ) } // ─── Handler ───────────────────────────────────────────────────────────────── @@ -232,6 +239,18 @@ func (h *ConfigureSystemHandler) OverviewNTP(w http.ResponseWriter, r *http.Requ data := cfgNTPPageData{ PageData: newPageData(w, r, "configure-ntp", "NTP Client"), } + data.AssocTypes = []string{"server", "pool", "peer"} + if mgr := h.Schema.Manager(); mgr != nil { + srv := "/ietf-system:system/ntp/server" + data.Desc = map[string]string{ + "name": schema.DescriptionOf(mgr, srv+"/name"), + "address": schema.DescriptionOf(mgr, srv+"/udp/address"), + "port": schema.DescriptionOf(mgr, srv+"/udp/port"), + "assoc": schema.DescriptionOf(mgr, srv+"/association-type"), + "iburst": schema.DescriptionOf(mgr, srv+"/iburst"), + "prefer": schema.DescriptionOf(mgr, srv+"/prefer"), + } + } s, errMsg := h.loadSystem(r) data.Error = errMsg if errMsg == "" { @@ -246,6 +265,15 @@ func (h *ConfigureSystemHandler) OverviewDNS(w http.ResponseWriter, r *http.Requ data := cfgDNSPageData{ PageData: newPageData(w, r, "configure-dns", "DNS Client"), } + if mgr := h.Schema.Manager(); mgr != nil { + dr := "/ietf-system:system/dns-resolver" + data.Desc = map[string]string{ + "search": schema.DescriptionOf(mgr, dr+"/search"), + "name": schema.DescriptionOf(mgr, dr+"/server/name"), + "address": schema.DescriptionOf(mgr, dr+"/server/udp-and-tcp/address"), + "port": schema.DescriptionOf(mgr, dr+"/server/udp-and-tcp/port"), + } + } s, errMsg := h.loadSystem(r) data.Error = errMsg if errMsg == "" { @@ -315,114 +343,220 @@ func (h *ConfigureSystemHandler) SaveClock(w http.ResponseWriter, r *http.Reques } // SaveNTP replaces the NTP server list in the candidate datastore. -// PUT /configure/system/ntp -func (h *ConfigureSystemHandler) SaveNTP(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, "bad request", http.StatusBadRequest) - return - } +// ntpCandPath is the NTP container in the candidate datastore. +const ntpCandPath = candidatePath + "/ietf-system:system/ietf-system:ntp" - servers := parseNTPServers(r) - ntp := map[string]any{"enabled": true} - if len(servers) > 0 { - ntp["server"] = servers - } - body := map[string]any{"ietf-system:ntp": ntp} - if err := h.RC.Put(r.Context(), candidatePath+"/ietf-system:system/ntp", body); err != nil { - log.Printf("configure system ntp: %v", err) - renderSaveError(w, err) - return - } - renderSaved(w, "NTP saved") +func ntpServerCandPath(name string) string { + return ntpCandPath + "/server=" + url.PathEscape(name) } -// SaveDNS replaces the DNS resolver config in the candidate datastore. -// PUT /configure/system/dns -func (h *ConfigureSystemHandler) SaveDNS(w http.ResponseWriter, r *http.Request) { +// ntpServerFromForm builds the RESTCONF body for one server, omitting the +// udp transport when no address is given (empty inet:host is invalid) — so a +// server can be added by name and have its address filled in via the +// expand-to-edit form afterwards. +func ntpServerFromForm(r *http.Request, name string) map[string]any { + srv := map[string]any{ + "name": name, + "association-type": defaultStr(r.FormValue("association-type"), "server"), + "iburst": r.FormValue("iburst") == "true", + "prefer": r.FormValue("prefer") == "true", + } + if addr := strings.TrimSpace(r.FormValue("address")); addr != "" { + udp := map[string]any{"address": addr} + if p, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("port")), 10, 16); err == nil && p > 0 { + udp["port"] = p + } + srv["udp"] = udp + } + return srv +} + +func defaultStr(v, def string) string { + if strings.TrimSpace(v) == "" { + return def + } + return v +} + +// AddNTPServer adds an NTP server, creating + enabling the NTP container on +// first use, then re-renders the page. +// POST /configure/system/ntp/servers +func (h *ConfigureSystemHandler) AddNTPServer(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } - - search := parseSearchList(r) - servers := parseDNSServers(r) - - // Omit empty lists entirely — sending null for a YANG list is invalid. - dnsResolver := map[string]any{} - if len(search) > 0 { - dnsResolver["search"] = search - } - if len(servers) > 0 { - dnsResolver["server"] = servers + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + renderSaveError(w, fmt.Errorf("server name is required")) + return } + // PATCH the system (always present) with the NTP container nested: it is + // created + enabled on first use and the server merges into the list. body := map[string]any{ - "ietf-system:dns-resolver": dnsResolver, + "ietf-system:system": map[string]any{ + "ietf-system:ntp": map[string]any{ + "enabled": true, + "server": []any{ntpServerFromForm(r, name)}, + }, + }, } - if err := h.RC.Put(r.Context(), candidatePath+"/ietf-system:system/dns-resolver", body); err != nil { - log.Printf("configure system dns: %v", err) + if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil { + log.Printf("configure ntp add server: %v", err) renderSaveError(w, err) return } - renderSaved(w, "DNS saved") + renderSavedRedirect(w, "NTP server added", "/configure/ntp") +} + +// SaveNTPServer replaces one NTP server's configuration (per-row edit). +// POST /configure/system/ntp/servers/{name} +func (h *ConfigureSystemHandler) SaveNTPServer(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + body := map[string]any{"ietf-system:server": []any{ntpServerFromForm(r, name)}} + if err := h.RC.Put(r.Context(), ntpServerCandPath(name), body); err != nil { + log.Printf("configure ntp save server %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Server saved") +} + +// DeleteNTPServer removes an NTP server. +// DELETE /configure/system/ntp/servers/{name} +func (h *ConfigureSystemHandler) DeleteNTPServer(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := h.RC.Delete(r.Context(), ntpServerCandPath(name)); err != nil && !restconf.IsNotFound(err) { + log.Printf("configure ntp delete server %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "NTP server deleted", "/configure/ntp") +} + +// dnsCandPath is the DNS resolver container in the candidate datastore. +const dnsCandPath = candidatePath + "/ietf-system:system/ietf-system:dns-resolver" + +func dnsServerCandPath(name string) string { + return dnsCandPath + "/server=" + url.PathEscape(name) +} + +func dnsServerFromForm(r *http.Request, name string) map[string]any { + srv := map[string]any{"name": name} + if addr := strings.TrimSpace(r.FormValue("address")); addr != "" { + ut := map[string]any{"address": addr} + if p, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("port")), 10, 16); err == nil && p > 0 { + ut["port"] = p + } + srv["udp-and-tcp"] = ut + } + return srv +} + +// AddDNSServer adds a DNS server, creating the resolver container on first +// use, then re-renders the page. +// POST /configure/system/dns/servers +func (h *ConfigureSystemHandler) AddDNSServer(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + renderSaveError(w, fmt.Errorf("server name is required")) + return + } + body := map[string]any{ + "ietf-system:system": map[string]any{ + "ietf-system:dns-resolver": map[string]any{"server": []any{dnsServerFromForm(r, name)}}, + }, + } + if err := h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", body); err != nil { + log.Printf("configure dns add server: %v", err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "DNS server added", "/configure/dns") +} + +// SaveDNSServer replaces one DNS server's configuration (per-row edit). +// POST /configure/system/dns/servers/{name} +func (h *ConfigureSystemHandler) SaveDNSServer(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + body := map[string]any{"ietf-system:server": []any{dnsServerFromForm(r, name)}} + if err := h.RC.Put(r.Context(), dnsServerCandPath(name), body); err != nil { + log.Printf("configure dns save server %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSaved(w, "Server saved") +} + +// DeleteDNSServer removes a DNS server. +// DELETE /configure/system/dns/servers/{name} +func (h *ConfigureSystemHandler) DeleteDNSServer(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if err := h.RC.Delete(r.Context(), dnsServerCandPath(name)); err != nil && !restconf.IsNotFound(err) { + log.Printf("configure dns delete server %q: %v", name, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "DNS server deleted", "/configure/dns") +} + +// AddDNSSearch appends a search domain to the resolver's search leaf-list. +// POST /configure/system/dns/search +func (h *ConfigureSystemHandler) AddDNSSearch(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + domain := strings.TrimSpace(r.FormValue("domain")) + if domain == "" { + renderSaveError(w, fmt.Errorf("domain is required")) + return + } + // POST appends to the leaf-list; if the resolver container doesn't exist + // yet, create it with this domain via a merge on the system. + err := h.RC.PostJSON(r.Context(), dnsCandPath, map[string]any{"ietf-system:search": []any{domain}}) + if restconf.IsNotFound(err) { + err = h.RC.Patch(r.Context(), candidatePath+"/ietf-system:system", map[string]any{ + "ietf-system:system": map[string]any{ + "ietf-system:dns-resolver": map[string]any{"search": []any{domain}}, + }, + }) + } + if err != nil { + log.Printf("configure dns add search: %v", err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Search domain added", "/configure/dns") +} + +// DeleteDNSSearch removes a search domain. +// DELETE /configure/system/dns/search/{domain} +func (h *ConfigureSystemHandler) DeleteDNSSearch(w http.ResponseWriter, r *http.Request) { + domain := r.PathValue("domain") + path := dnsCandPath + "/search=" + url.PathEscape(domain) + if err := h.RC.Delete(r.Context(), path); err != nil && !restconf.IsNotFound(err) { + log.Printf("configure dns delete search %q: %v", domain, err) + renderSaveError(w, err) + return + } + renderSavedRedirect(w, "Search domain removed", "/configure/dns") } // ─── Form parsing helpers ───────────────────────────────────────────────────── -// parseNTPServers extracts NTP server entries from form values. -// Fields: ntp_name_N, ntp_addr_N, ntp_port_N, ntp_prefer_N (checkbox). -func parseNTPServers(r *http.Request) []cfgNTPServerJSON { - var servers []cfgNTPServerJSON - for i := 0; ; i++ { - name := strings.TrimSpace(r.FormValue("ntp_name_" + strconv.Itoa(i))) - if name == "" { - break - } - addr := strings.TrimSpace(r.FormValue("ntp_addr_" + strconv.Itoa(i))) - port, _ := strconv.ParseUint(r.FormValue("ntp_port_"+strconv.Itoa(i)), 10, 16) - prefer := r.FormValue("ntp_prefer_"+strconv.Itoa(i)) == "on" - srv := cfgNTPServerJSON{ - Name: name, - UDP: cfgNTPUDPJSON{Address: addr, Port: uint16(port)}, - Prefer: prefer, - } - servers = append(servers, srv) - } - return servers -} - -// parseSearchList extracts DNS search domains from form values. -// Fields: dns_search_N (one per domain). -func parseSearchList(r *http.Request) []string { - var search []string - for i := 0; ; i++ { - v := strings.TrimSpace(r.FormValue("dns_search_" + strconv.Itoa(i))) - if v == "" { - break - } - search = append(search, v) - } - return search -} - -// parseDNSServers extracts DNS server entries from form values. -// Fields: dns_name_N, dns_addr_N, dns_port_N. -func parseDNSServers(r *http.Request) []cfgDNSServerJSON { - var servers []cfgDNSServerJSON - for i := 0; ; i++ { - name := strings.TrimSpace(r.FormValue("dns_name_" + strconv.Itoa(i))) - if name == "" { - break - } - addr := strings.TrimSpace(r.FormValue("dns_addr_" + strconv.Itoa(i))) - port, _ := strconv.ParseUint(r.FormValue("dns_port_"+strconv.Itoa(i)), 10, 16) - srv := cfgDNSServerJSON{ - Name: name, - UDPAndTCP: cfgDNSAddrJSON{Address: addr, Port: uint16(port)}, - } - servers = append(servers, srv) - } - return servers -} // SavePreferences patches infix-system augmented fields (motd-banner, text-editor). // POST /configure/system/preferences diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 766de368..0c9e60a8 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -351,8 +351,14 @@ func New( mux.HandleFunc("GET /configure/dns", cfgSys.OverviewDNS) mux.HandleFunc("POST /configure/system/identity", cfgSys.SaveIdentity) mux.HandleFunc("POST /configure/system/clock", cfgSys.SaveClock) - mux.HandleFunc("PUT /configure/system/ntp", cfgSys.SaveNTP) - mux.HandleFunc("PUT /configure/system/dns", cfgSys.SaveDNS) + mux.HandleFunc("POST /configure/system/ntp/servers", cfgSys.AddNTPServer) + mux.HandleFunc("POST /configure/system/ntp/servers/{name}", cfgSys.SaveNTPServer) + mux.HandleFunc("DELETE /configure/system/ntp/servers/{name}", cfgSys.DeleteNTPServer) + mux.HandleFunc("POST /configure/system/dns/servers", cfgSys.AddDNSServer) + mux.HandleFunc("POST /configure/system/dns/servers/{name}", cfgSys.SaveDNSServer) + mux.HandleFunc("DELETE /configure/system/dns/servers/{name}", cfgSys.DeleteDNSServer) + mux.HandleFunc("POST /configure/system/dns/search", cfgSys.AddDNSSearch) + mux.HandleFunc("DELETE /configure/system/dns/search/{domain}", cfgSys.DeleteDNSSearch) mux.HandleFunc("POST /configure/system/preferences", cfgSys.SavePreferences) mux.HandleFunc("GET /configure/interfaces", cfgIf.Overview) mux.HandleFunc("POST /configure/interfaces", cfgIf.CreateInterface) diff --git a/src/webui/templates/pages/configure-dns.html b/src/webui/templates/pages/configure-dns.html index df4c9b0a..c6d6be2e 100644 --- a/src/webui/templates/pages/configure-dns.html +++ b/src/webui/templates/pages/configure-dns.html @@ -8,75 +8,117 @@
    {{.Error}}
    {{end}} -
    -
    DNS
    +
    +
    Search Domains
    +
    + + + + {{range .DNS.Search}} + + + + + {{else}} + + {{end}} + +
    Domain{{template "field-info" (index .Desc "search")}}
    {{.}} + +
    No search domains configured.
    +
    + + +
    -
    Search domains
    -
    - - - - - - {{range $i, $d := .DNS.Search}} - - - - - {{end}} - -
    Domain
    - -
    -
    -
    - -
    - -
    Servers
    -
    - - - - - - - - - - - {{range $i, $s := .DNS.Servers}} - - - - - - - {{end}} - -
    Name / KeyAddressPort
    - -
    -
    - - +
    +
    DNS Servers
    +
    + + + + + + + + + + + {{range $i, $s := .DNS.Servers}} + + + + + + + + + + {{else}} + + {{end}} + +
    Name{{template "field-info" (index .Desc "name")}}Address{{template "field-info" (index .Desc "address")}}Port{{template "field-info" (index .Desc "port")}}
    + + {{if $s.UDPAndTCP.Address}}{{$s.UDPAndTCP.Address}}{{else}}{{end}}{{if $s.UDPAndTCP.Port}}{{$s.UDPAndTCP.Port}}{{else}}53{{end}} + +
    +
    +
    + + + + + + + + + +
    Address{{template "field-info" (index $.Desc "address")}}
    Port{{template "field-info" (index $.Desc "port")}}
    + +
    +
    +
    No DNS servers configured.
    +
    + + +
    {{template "configure-toolbar" .}}
    {{end}} diff --git a/src/webui/templates/pages/configure-ntp.html b/src/webui/templates/pages/configure-ntp.html index 28cc6772..f8dd9e80 100644 --- a/src/webui/templates/pages/configure-ntp.html +++ b/src/webui/templates/pages/configure-ntp.html @@ -8,52 +8,107 @@
    {{.Error}}
    {{end}} -
    -
    NTP Servers
    -
    - - - - - - - - - - - - {{range $i, $s := .NTP.Servers}} - - - - - - - - {{end}} - -
    Name / KeyAddressPortPrefer
    - - - -
    -
    - -
    +
    +
    NTP Servers
    +
    + + + + + + + + + + + + {{range $i, $s := .NTP.Servers}} + + + + + + + + + + + {{else}} + + {{end}} + +
    Name{{template "field-info" (index .Desc "name")}}Address{{template "field-info" (index .Desc "address")}}Port{{template "field-info" (index .Desc "port")}}Prefer{{template "field-info" (index .Desc "prefer")}}
    + + {{if $s.UDP.Address}}{{$s.UDP.Address}}{{else}}{{end}}{{if $s.UDP.Port}}{{$s.UDP.Port}}{{else}}123{{end}}{{if $s.Prefer}}★{{end}} + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + +
    Address{{template "field-info" (index $.Desc "address")}}
    Port{{template "field-info" (index $.Desc "port")}}
    Association{{template "field-info" (index $.Desc "assoc")}} + +
    iburst{{template "field-info" (index $.Desc "iburst")}}
    Prefer{{template "field-info" (index $.Desc "prefer")}}
    + +
    +
    +
    No NTP servers configured.
    +
    + + {{/* Hidden add-server form, revealed by the + Add server button (data-show) */}} + + +
    {{template "configure-toolbar" .}}
  • {{end}} From 9272ae382dc942df52bdf9c98725ab14c3ca9059 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 14 Jun 2026 17:05:31 +0200 Subject: [PATCH 51/59] webui: type-aware Config column in interface overview The collapsed Interfaces list left DHCP/SLAAC/zeroconf invisible until a row was unfolded, so L3 interfaces showed only empty cells. Replace the Addresses column with a Config column whose pills are chosen by role, mirroring the Status > Interfaces data column but slanted to configured intent: vid for VLANs, mode for WiFi, 802.1Q for filtering bridges, and DHCPv4/DHCPv6/SLAAC/Zeroconf for IP interfaces, alongside any static addresses. Signed-off-by: Joachim Wiberg --- .../internal/handlers/configure_interfaces.go | 39 +++++++++++++++++++ src/webui/static/css/style.css | 19 +++++++++ .../templates/pages/configure-interfaces.html | 8 +++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/webui/internal/handlers/configure_interfaces.go b/src/webui/internal/handlers/configure_interfaces.go index 0389988c..f8fa4ca6 100644 --- a/src/webui/internal/handlers/configure_interfaces.go +++ b/src/webui/internal/handlers/configure_interfaces.go @@ -94,6 +94,7 @@ type cfgIfaceRow struct { AdminEnabled bool // true when enabled leaf absent (YANG default) or explicitly true MemberOf string // bridge or lag name this interface belongs to AddrSummary string + ConfigTags []string // type-aware overview pills (DHCP, SLAAC, vid N, …) BridgeMembers []string // interface names that are ports of this bridge/lag BridgeMemberSet map[string]bool // for checkbox pre-selection PortCandidates []string // free ports + current members of this bridge/lag @@ -2042,6 +2043,7 @@ func (h *ConfigureInterfacesHandler) buildRows(ifaces []ifaceJSON, oper []ifaceJ } } row.AddrSummary = addrSummary(iface) + row.ConfigTags = configSummary(&row) if row.IsBridge { members := bridgeMembers[iface.Name] @@ -2647,3 +2649,40 @@ func addrSummary(iface ifaceJSON) string { return fmt.Sprintf("%d addresses", len(addrs)) } } + +// configSummary builds the type-aware overview pills for an interface in the +// Configure list. It mirrors the Status > Interfaces "Data" column but is +// slanted to configured intent rather than operational result: each role +// contributes the properties worth seeing without unfolding the row — IP +// acquisition (DHCP, SLAAC, zeroconf) for L3 interfaces, the tag id for VLANs, +// the radio mode for WiFi, and 802.1Q for VLAN-filtering bridges. Static +// addresses are rendered separately via AddrSummary. +func configSummary(row *cfgIfaceRow) []string { + var tags []string + + if row.IsVlan && row.Vlan != nil { + tags = append(tags, fmt.Sprintf("vid %d", row.Vlan.ID)) + } + if row.IsWifi && row.WifiMode != "" { + tags = append(tags, row.WifiMode) + } + if row.IsBridge && row.BridgeIs8021Q { + tags = append(tags, "802.1Q") + } + if row.HasIP { + if row.DHCPv4Enabled { + tags = append(tags, "DHCPv4") + } + if row.IPv4 != nil && row.IPv4.Autoconf != nil { + tags = append(tags, "Zeroconf") + } + if row.DHCPv6Enabled { + tags = append(tags, "DHCPv6") + } + if row.IPv6 != nil && row.IPv6.SLAACv6 != nil { + tags = append(tags, "SLAAC") + } + } + + return tags +} diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index d8cec957..0ef816c2 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -1099,6 +1099,25 @@ details[open].nav-group-top > summary.nav-group-summary-top::before { .badge-drop { background: var(--slate-100); color: var(--slate-600); } .badge-continue { background: #dbeafe; color: #1e40af; } +/* Type-aware Config column pills in the Configure > Interfaces overview. */ +.iface-cfg-cell { white-space: normal; } +.iface-tag { + display: inline-block; + margin: 0.1rem 0.25rem 0.1rem 0; + padding: 0.05rem 0.4rem; + border-radius: var(--radius-sm); + background: var(--slate-100); + color: var(--slate-600); + font-size: 0.7rem; + font-weight: 600; + white-space: nowrap; +} +.iface-cfg-addr { color: var(--slate-600); } +.dark .iface-tag { background: var(--slate-800); color: var(--slate-300); } +@media (prefers-color-scheme: dark) { + .iface-tag { background: var(--slate-800); color: var(--slate-300); } +} + @media (prefers-color-scheme: dark) { .badge-accept { background: rgba(22, 163, 74, 0.2); color: #86efac; } .badge-reject { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } diff --git a/src/webui/templates/pages/configure-interfaces.html b/src/webui/templates/pages/configure-interfaces.html index 841cb0dc..366caa92 100644 --- a/src/webui/templates/pages/configure-interfaces.html +++ b/src/webui/templates/pages/configure-interfaces.html @@ -30,7 +30,7 @@ Type Status Member of - Addresses + Config @@ -54,7 +54,11 @@ {{end}} {{if .MemberOf}}{{.MemberOf}}{{else}}{{end}} - {{if .AddrSummary}}{{.AddrSummary}}{{else}}{{end}} + + {{range .ConfigTags}}{{.}}{{end}} + {{if .AddrSummary}}{{.AddrSummary}}{{end}} + {{if and (not .ConfigTags) (not .AddrSummary)}}{{end}} + {{if ne .TypeSlug "loopback"}} - {{if .Capabilities.Has "docs"}} + {{if or (.Capabilities.Has "docs") (.Capabilities.Has "netbrowse") (.Capabilities.Has "console")}} + {{if .Capabilities.Has "docs"}} {{template "icon-book"}} User Guide {{end}} + {{if .Capabilities.Has "netbrowse"}} + + {{template "icon-radar"}} + Network Browser + + {{end}} + {{if .Capabilities.Has "console"}} + {{/* href filled in by JS from the current hostname (ttyd on :7681). */}} + + {{template "icon-terminal"}} + Console + + {{end}} + {{end}}
    diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index eb3a812c..62adddf0 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -73,6 +73,26 @@ Interfaces +
  • + + + Routes + +
  • +
  • + + + Firewall + +
  • - {{if .Capabilities.Has "netbrowse"}} - {{/* External link to the mDNS network browser at network.local - (new tab); not an htmx nav. */}} -
  • - - - Network Browser - -
  • - {{end}}
  • -
  • - - - Routes - -
  • -
  • - - - Firewall - -
  • {{if and .Capabilities (.Capabilities.Has "wifi")}}
  • - {{if .Capabilities.Has "console"}} - {{/* External link to ttyd on :7681 (new tab); not an htmx nav. - href is filled in by JS from the current hostname. */}} -
  • - - - Console - -
  • - {{end}}
  • Date: Sun, 14 Jun 2026 18:15:25 +0200 Subject: [PATCH 53/59] webui: add automatic .cfg migration when restoring a backup Also adjust some card layout in both Backup and Restore cards. Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/system.go | 55 ++++++++++++++++++++++++++- src/webui/static/css/style.css | 13 +++++++ src/webui/templates/pages/backup.html | 6 ++- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go index 4fadd8fc..3a63d8ff 100644 --- a/src/webui/internal/handlers/system.go +++ b/src/webui/internal/handlers/system.go @@ -273,6 +273,49 @@ func (h *SystemHandler) SupportBundle(w http.ResponseWriter, r *http.Request) { // target="running" (default): PUT to running so changes take effect immediately; // sets the cfg-unsaved cookie so the persistent notification prompts a save. // target="startup": PUT to startup only; reboot required to apply. +// migrateConfig runs an uploaded .cfg backup through the on-target migrate(1) +// tool so a config saved by an older release is brought up to the running +// syntax before it is applied. The config is written to a temp file and +// migrated in place — migrate only reads stdin from a TTY, so the file path is +// the reliable interface. A config already at the current version comes back +// unchanged. An error carrying migrate's stderr is returned for a config newer +// than the system supports (downgrade) or any other migrate failure. +func migrateConfig(ctx context.Context, raw []byte) ([]byte, error) { + f, err := os.CreateTemp("", "restore-*.cfg") + if err != nil { + return nil, err + } + tmp := f.Name() + defer os.Remove(tmp) + + if _, err := f.Write(raw); err != nil { + f.Close() + return nil, err + } + if err := f.Close(); err != nil { + return nil, err + } + + var stderr bytes.Buffer + cmd := exec.CommandContext(ctx, "/usr/sbin/migrate", "-i", "-e", tmp) + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + msg = strings.ReplaceAll(msg, tmp+": ", "") + msg = strings.TrimPrefix(msg, "Error: ") + if msg == "" { + msg = err.Error() + } + return nil, fmt.Errorf("%s", msg) + } + + if notes := strings.TrimSpace(stderr.String()); notes != "" { + log.Printf("restore: migrated uploaded config:\n%s", notes) + } + + return os.ReadFile(tmp) +} + func (h *SystemHandler) RestoreConfig(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(10 << 20); err != nil { http.Error(w, "bad request", http.StatusBadRequest) @@ -304,12 +347,22 @@ func (h *SystemHandler) RestoreConfig(w http.ResponseWriter, r *http.Request) { return } + // Bring an older backup up to the running syntax before applying it. + migrated, err := migrateConfig(r.Context(), raw) + if err != nil { + log.Printf("restore: migrate: %v", err) + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, `Restore failed: %s`, + template.HTMLEscapeString(err.Error())) + return + } + target := "running" if r.FormValue("save-to-startup") == "on" { target = "startup" } - if err := h.RC.PutDatastore(r.Context(), target, check); err != nil { + if err := h.RC.PutDatastore(r.Context(), target, json.RawMessage(migrated)); err != nil { log.Printf("restore(%s): %v", target, err) w.Header().Set("Content-Type", "text/html") fmt.Fprintf(w, `Restore failed: %s`, diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 0ef816c2..db935cc5 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -2813,6 +2813,19 @@ details.cfg-fold[open] > summary::before { transform: rotate(90deg); } margin-top: 0.75rem; cursor: pointer; } + +/* Restore card: let the form fill the body so its file selector and checkbox + sit under the leading paragraph, with only the submit button anchored at the + bottom (aligned with the other action cards). */ +#restore-form { + display: flex; + flex-direction: column; + flex: 1; +} +.sc-form-actions { + margin-top: auto; + padding-top: 0.75rem; +} .sc-restore-warn-text { color: var(--warning-fg); font-size: 0.875rem; diff --git a/src/webui/templates/pages/backup.html b/src/webui/templates/pages/backup.html index b1da3ad2..71083559 100644 --- a/src/webui/templates/pages/backup.html +++ b/src/webui/templates/pages/backup.html @@ -9,7 +9,7 @@
    Backup

    Download the current startup configuration as a JSON file. - The file format is

    startup-config-$hostname-$date-$time.cfg
    + The file format is startup-config-$hostname-$date-$time.cfg

    Download config @@ -22,6 +22,8 @@

    Restore a previously backed-up configuration. By default the restore operation is made to running config, this is the safe default.

    +

    If the file was saved by an older release, it is migrated to + the current configuration syntax before being applied.

    Save to startup — may break system boot!
    -
    +
    From a975f55284c7c10a3df759df110d7112ac4beea9 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 14 Jun 2026 19:49:46 +0200 Subject: [PATCH 54/59] webui: show device hostname in topbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the configured hostname right of the logo, pinned to the sidebar's right edge so it reads as the start of the content area. When location and/or contact are set they appear in a hover/focus popover, so the bar stays uncluttered; the name links to Configure > System. Loaded asynchronously (hx-trigger="load") to keep it out of every page's data path — the topbar persists across htmx swaps, so it fetches once per full page load and renders nothing on error or when no hostname is set. Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/system.go | 32 +++++++- src/webui/internal/server/server.go | 14 +++- src/webui/static/css/style.css | 77 +++++++++++++++++++ .../templates/fragments/topbar-identity.html | 17 ++++ src/webui/templates/layouts/base.html | 3 + 5 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 src/webui/templates/fragments/topbar-identity.html diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go index 3a63d8ff..f7a20dec 100644 --- a/src/webui/internal/handlers/system.go +++ b/src/webui/internal/handlers/system.go @@ -37,10 +37,11 @@ func raucInstallationStatus(ctx context.Context) (swInstallerState, error) { // SystemHandler provides reboot, config download, and software install actions. type SystemHandler struct { - RC *restconf.Client - Template *template.Template // software page template - SysCtrlTmpl *template.Template // system control page template - BackupTmpl *template.Template // backup & restore page template + RC *restconf.Client + Template *template.Template // software page template + SysCtrlTmpl *template.Template // system control page template + BackupTmpl *template.Template // backup & restore page template + IdentityTmpl *template.Template // topbar device-identity fragment // swSlots caches the last successfully-fetched Software card payload // so /software?installing=1 can keep rendering slot details — the @@ -316,6 +317,29 @@ func migrateConfig(ctx context.Context, raw []byte) ([]byte, error) { return os.ReadFile(tmp) } +// Identity renders the topbar device-identity widget — hostname, plus an +// optional location/contact hover popover. It is loaded asynchronously via +// hx-trigger="load" so it stays out of the per-page data path; the topbar +// persists across htmx content swaps, so this fetches once per full page load. +// On any fetch error it renders nothing, leaving the topbar slot empty. +func (h *SystemHandler) Identity(w http.ResponseWriter, r *http.Request) { + var resp struct { + System struct { + Hostname string `json:"hostname"` + Location string `json:"location"` + Contact string `json:"contact"` + } `json:"ietf-system:system"` + } + if err := h.RC.Get(r.Context(), "/data/ietf-system:system", &resp); err != nil { + log.Printf("identity: %v", err) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.IdentityTmpl.ExecuteTemplate(w, "topbar-identity", resp.System); err != nil { + log.Printf("identity: render: %v", err) + } +} + func (h *SystemHandler) RestoreConfig(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(10 << 20); err != nil { http.Error(w, "bad request", http.StatusBadRequest) diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 0c9e60a8..c565c13c 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -65,6 +65,10 @@ func New( if err != nil { return nil, err } + identityTmpl, err := template.ParseFS(templateFS, "fragments/topbar-identity.html") + if err != nil { + return nil, err + } logsTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/logs.html") if err != nil { return nil, err @@ -238,10 +242,11 @@ func New( } sys := &handlers.SystemHandler{ - RC: rc, - Template: swTmpl, - SysCtrlTmpl: sysCtrlTmpl, - BackupTmpl: backupTmpl, + RC: rc, + Template: swTmpl, + SysCtrlTmpl: sysCtrlTmpl, + BackupTmpl: backupTmpl, + IdentityTmpl: identityTmpl, } logs := &handlers.LogsHandler{Template: logsTmpl} diag := &handlers.DiagnosticsHandler{RC: rc, Template: diagTmpl} @@ -338,6 +343,7 @@ func New( mux.HandleFunc("GET /nacm", nacm.Overview) mux.HandleFunc("GET /services", services.Overview) mux.HandleFunc("GET /containers", containers.Overview) + mux.HandleFunc("GET /identity", sys.Identity) // Configure routes. mux.HandleFunc("POST /configure/enter", cfg.Enter) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index db935cc5..6fdbe6f5 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -237,6 +237,83 @@ a:hover { display: block; } +/* Device identity: hostname shown right of the logo, reading like a titlebar. + Location/contact (when set) appear in a hover/focus popover so the bar stays + uncluttered. */ +.topbar-identity { + position: absolute; + left: var(--sidebar-width); + top: 0; + bottom: 0; + display: flex; + align-items: center; + min-width: 0; + padding-left: 0.9rem; +} +/* Short divider pinned to the sidebar's right edge, so the hostname reads as + the start of the content area rather than part of the logo lockup. */ +.topbar-identity::before { + content: ""; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 1px; + height: 1.4rem; + background: var(--sidebar-border); +} +.topbar-identity-name { + display: inline-flex; + align-items: center; + gap: 0.25rem; + max-width: 22rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.875rem; + font-weight: 500; + color: var(--fg-muted); + text-decoration: none; + padding: 0.2rem 0.45rem; + border-radius: var(--radius-sm); + transition: color 0.15s, background 0.15s; +} +.topbar-identity-name:hover { background: var(--slate-100); color: var(--fg); } +.dark .topbar-identity-name:hover { background: var(--slate-800); color: var(--fg); } +.topbar-identity-caret { font-size: 0.6rem; opacity: 0.6; } + +.topbar-identity-pop { + display: none; + position: absolute; + top: 100%; + left: 0; + margin-top: 0.3rem; + padding: 0.5rem 0.7rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); + white-space: nowrap; + z-index: 250; + font-size: 0.8rem; +} +.topbar-identity:hover .topbar-identity-pop, +.topbar-identity:focus-within .topbar-identity-pop { display: block; } +.topbar-identity-pop .ti-row { display: flex; gap: 0.6rem; } +.topbar-identity-pop .ti-row + .ti-row { margin-top: 0.25rem; } +.topbar-identity-pop .ti-label { + color: var(--fg-muted); + font-weight: 600; + min-width: 4.5rem; +} + +/* Narrow screens: the hamburger takes over — drop identity so it never + competes with the menu button. */ +@media (max-width: 1024px) { + .topbar-identity, + .topbar-identity-slot { display: none; } +} + .sidebar-nav { flex: 1; padding: 0.5rem 0; diff --git a/src/webui/templates/fragments/topbar-identity.html b/src/webui/templates/fragments/topbar-identity.html new file mode 100644 index 00000000..0a6bcf94 --- /dev/null +++ b/src/webui/templates/fragments/topbar-identity.html @@ -0,0 +1,17 @@ +{{define "topbar-identity"}} +{{if .Hostname}} +
    + + {{.Hostname}}{{if or .Location .Contact}}{{end}} + + {{if or .Location .Contact}} + + {{end}} +
    +{{end}} +{{end}} diff --git a/src/webui/templates/layouts/base.html b/src/webui/templates/layouts/base.html index 4eb345c9..31ca736f 100644 --- a/src/webui/templates/layouts/base.html +++ b/src/webui/templates/layouts/base.html @@ -23,6 +23,9 @@ {{template "icon-menu"}} + {{/* Device identity (hostname + location/contact popover), loaded async so + it stays out of every page's data path. Replaces itself on load. */}} +
    {{if .Capabilities.Has "docs"}} {{/* On-device User's Guide (static mkdocs site at /guide/). */}} From d326939e10fa7343fdfa3d5c543962f7dfb5e412 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 15 Jun 2026 10:44:29 +0200 Subject: [PATCH 55/59] webui: final touches - Add missing Configure -> shortcuts - For shortcuts into YANG tree, expand and mark the tree node - Revamp the /mdns status page to mimic /ntp => more vertical space Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/configure.go | 39 ++++-- .../internal/handlers/configure_interfaces.go | 90 +++++++++++-- src/webui/static/css/style.css | 8 ++ src/webui/static/js/app.js | 124 ++++++++++++++++- .../templates/pages/configure-interfaces.html | 4 +- src/webui/templates/pages/containers.html | 16 ++- src/webui/templates/pages/firewall.html | 8 +- src/webui/templates/pages/interfaces.html | 16 ++- src/webui/templates/pages/mdns.html | 127 ++++++++---------- src/webui/templates/pages/ntp.html | 12 +- src/webui/templates/pages/vpn.html | 13 +- src/webui/templates/pages/wifi.html | 5 + src/webui/templates/pages/yang-tree.html | 2 +- 13 files changed, 363 insertions(+), 101 deletions(-) diff --git a/src/webui/internal/handlers/configure.go b/src/webui/internal/handlers/configure.go index e7d7c054..75f9d9cc 100644 --- a/src/webui/internal/handlers/configure.go +++ b/src/webui/internal/handlers/configure.go @@ -3,6 +3,8 @@ package handlers import ( + "encoding/json" + "errors" "log" "net/http" "strconv" @@ -74,13 +76,37 @@ func (h *ConfigureHandler) Enter(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// applyError surfaces a failed datastore copy from Apply / Apply&Save / Save. +// A RESTCONF error means the device answered and rejected the config — almost +// always a validation failure (e.g. deleting an interface other nodes still +// reference). The device is reachable, so report the reason inline via an +// HX-Trigger and a 200, NOT a 502: the front-end's connection monitor reads +// 502/503/504 as "device disconnected", which is exactly wrong here. Only a +// transport failure (no RESTCONF response at all) is a real gateway error. +func applyError(w http.ResponseWriter, op string, err error) { + log.Printf("configure %s: %v", op, err) + var re *restconf.Error + if errors.As(err, &re) { + // parseError always populates Message (server text, else HTTP status + // text), so a single fallback covers the otherwise-unreachable empty. + msg := re.Message + if msg == "" { + msg = "the device rejected the configuration" + } + b, _ := json.Marshal(msg) + w.Header().Set("HX-Trigger", `{"cfgApplyError":`+string(b)+`}`) + w.WriteHeader(http.StatusOK) + return + } + 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. // POST /configure/apply func (h *ConfigureHandler) Apply(w http.ResponseWriter, r *http.Request) { if err := h.RC.CopyDatastore(r.Context(), "candidate", "running"); err != nil { - log.Printf("configure apply: %v", err) - http.Error(w, "Could not apply configuration: "+err.Error(), http.StatusBadGateway) + applyError(w, "apply", err) return } setCfgUnsaved(w) @@ -104,13 +130,11 @@ func (h *ConfigureHandler) Abort(w http.ResponseWriter, r *http.Request) { // POST /configure/apply-and-save func (h *ConfigureHandler) ApplyAndSave(w http.ResponseWriter, r *http.Request) { if err := h.RC.CopyDatastore(r.Context(), "candidate", "running"); err != nil { - log.Printf("configure apply-and-save: %v", err) - http.Error(w, "Could not apply configuration: "+err.Error(), http.StatusBadGateway) + applyError(w, "apply-and-save", err) return } if err := h.RC.CopyDatastore(r.Context(), "running", "startup"); err != nil { - log.Printf("configure apply-and-save (save): %v", err) - http.Error(w, "Could not save configuration: "+err.Error(), http.StatusBadGateway) + applyError(w, "apply-and-save (save)", err) return } clearCfgUnsaved(w) @@ -142,8 +166,7 @@ func (h *ConfigureHandler) DeleteLeaf(w http.ResponseWriter, r *http.Request) { // POST /configure/save func (h *ConfigureHandler) Save(w http.ResponseWriter, r *http.Request) { if err := h.RC.CopyDatastore(r.Context(), "running", "startup"); err != nil { - log.Printf("configure save: %v", err) - http.Error(w, "Could not save configuration: "+err.Error(), http.StatusBadGateway) + applyError(w, "save", err) return } clearCfgUnsaved(w) diff --git a/src/webui/internal/handlers/configure_interfaces.go b/src/webui/internal/handlers/configure_interfaces.go index f8fa4ca6..f2073427 100644 --- a/src/webui/internal/handlers/configure_interfaces.go +++ b/src/webui/internal/handlers/configure_interfaces.go @@ -141,6 +141,11 @@ type cfgIfaceRow struct { // foldout auto-expands to reveal the freshly-inferred options. False // on the normal page render, so foldouts stay collapsed there. JustSaved bool + // SavedMsg carries a confirmation rendered inline in the block footer on + // a post-save re-render. The block is swapped via outerHTML, so the + // triggering form (and its status span) is gone by the time any JS event + // fires — server-rendering the confirmation is the only reliable place. + SavedMsg string } // ifaceRadioMirror is the subset of wifi-radio fields we expose on the @@ -1130,6 +1135,15 @@ func (h *ConfigureInterfacesHandler) DeleteInterface(w http.ResponseWriter, r *h renderSaveError(w, fmt.Errorf("loopback interface cannot be deleted")) return } + // Clear other interfaces' references to this one first: detach enslaved + // bridge/LAG members, and refuse outright if a VLAN is stacked on top. + // Otherwise the dangling leafref fails the next Apply with an opaque + // "Invalid input data" and no hint which node broke. + if err := h.resolveDependents(r.Context(), name); err != nil { + log.Printf("configure interfaces %s resolve dependents: %v", name, err) + renderSaveError(w, err) + return + } if err := h.RC.Delete(r.Context(), ifacePath(name)); err != nil && !restconf.IsNotFound(err) { log.Printf("configure interfaces %s delete: %v", name, err) renderSaveError(w, err) @@ -1138,6 +1152,56 @@ func (h *ConfigureInterfacesHandler) DeleteInterface(w http.ResponseWriter, r *h renderSavedRedirect(w, name+" deleted", "/configure/interfaces") } +// resolveDependents prepares the named interface for deletion by clearing every +// other interface's reference to it, so the delete doesn't leave a dangling +// leafref that fails the next Apply with an opaque "Invalid input data" and no +// hint which node broke. +// +// - Enslaved bridge/LAG members are detached; they survive as standalone +// interfaces, only the membership is dropped. +// - VLANs stacked on the master via lower-layer-if can't survive without it, +// so rather than silently deleting an interface the user didn't name, the +// delete is refused and the VLANs named — removing them first is the user's +// call. +// +// The VLAN check runs before any detach, so a refused delete leaves the +// candidate untouched. +func (h *ConfigureInterfacesHandler) resolveDependents(ctx context.Context, master string) error { + ifaces, err := h.fetchAllInterfaces(ctx) + if err != nil { + return err + } + + var vlans []string + for _, iface := range ifaces { + if iface.Vlan != nil && iface.Vlan.LowerLayerIf == master { + vlans = append(vlans, iface.Name) + } + } + if len(vlans) > 0 { + noun, verb, pron := "VLAN", "is", "it" + if len(vlans) > 1 { + noun, verb, pron = "VLANs", "are", "them" + } + return fmt.Errorf("cannot delete %s: %s %s %s built on it — delete %s first", + master, noun, strings.Join(vlans, ", "), verb, pron) + } + + for _, iface := range ifaces { + switch { + case iface.BridgePort != nil && iface.BridgePort.Bridge == master: + if err := h.removeInterfaceAugment(ctx, iface.Name, "infix-interfaces:bridge-port"); err != nil { + return fmt.Errorf("detach %s from bridge %s: %w", iface.Name, master, err) + } + case iface.LagPort != nil && iface.LagPort.LAG == master: + if err := h.removeInterfaceAugment(ctx, iface.Name, "infix-interfaces:lag-port"); err != nil { + return fmt.Errorf("detach %s from lag %s: %w", iface.Name, master, err) + } + } + } + return nil +} + // SaveGeneral saves description, enabled, and optional custom MAC for any // interface. Empty MAC clears the override (DELETE on custom-phys-address); // non-empty installs it as a static override. @@ -1149,12 +1213,14 @@ func (h *ConfigureInterfacesHandler) SaveGeneral(w http.ResponseWriter, r *http. } name := r.PathValue("name") enabled := r.FormValue("enabled") != "false" + // PATCH on a list element needs the entry wrapped in a single-element + // array, not a bare object — a bare object fails YANG validation (LY_EVALID). body := map[string]any{ - "ietf-interfaces:interface": map[string]any{ + "ietf-interfaces:interface": []map[string]any{{ "name": name, "enabled": enabled, "description": strings.TrimSpace(r.FormValue("description")), - }, + }}, } if err := h.RC.Patch(r.Context(), ifacePath(name), body); err != nil { log.Printf("configure interfaces %s general: %v", name, err) @@ -2153,7 +2219,7 @@ func (h *ConfigureInterfacesHandler) addAddr(w http.ResponseWriter, r *http.Requ } // Re-render the IP block in place so the new address appears without // collapsing the interface foldout — ready to add another straight away. - h.renderIPBlock(w, r, name, famCap, frag) + h.renderIPBlock(w, r, name, famCap, frag, "Address added") } func (h *ConfigureInterfacesHandler) deleteAddr(w http.ResponseWriter, r *http.Request, family, famCap, frag string) { @@ -2165,7 +2231,7 @@ func (h *ConfigureInterfacesHandler) deleteAddr(w http.ResponseWriter, r *http.R renderSaveError(w, err) return } - h.renderIPBlock(w, r, name, famCap, frag) + h.renderIPBlock(w, r, name, famCap, frag, "Address removed") } // SaveIPv4Settings PATCHes the per-interface IPv4 group settings — forwarding @@ -2248,7 +2314,7 @@ func (h *ConfigureInterfacesHandler) saveIPSettings(w http.ResponseWriter, r *ht // without collapsing the page. fragName empty → fall back to a toast // (e.g. families whose block fragment isn't wired up yet). if fragName != "" { - h.renderIPBlock(w, r, name, family, fragName) + h.renderIPBlock(w, r, name, family, fragName, family+" settings saved") return } renderSaved(w, family+" settings saved") @@ -2258,10 +2324,10 @@ func (h *ConfigureInterfacesHandler) saveIPSettings(w http.ResponseWriter, r *ht // fresh candidate. Falls back to a plain saved-toast if the interface or // schema can't be read, so a save is never reported as failed just because // the in-place refresh couldn't be built. -func (h *ConfigureInterfacesHandler) renderIPBlock(w http.ResponseWriter, r *http.Request, name, family, fragName string) { +func (h *ConfigureInterfacesHandler) renderIPBlock(w http.ResponseWriter, r *http.Request, name, family, fragName, savedMsg string) { ifaces, err := h.fetchAllInterfaces(r.Context()) if err != nil { - renderSaved(w, family+" settings saved") + renderSaved(w, savedMsg) return } rows := h.buildRows(ifaces, nil) @@ -2273,7 +2339,7 @@ func (h *ConfigureInterfacesHandler) renderIPBlock(w http.ResponseWriter, r *htt } } if row == nil { - renderSaved(w, family+" settings saved") + renderSaved(w, savedMsg) return } if mgr := h.Schema.Manager(); mgr != nil { @@ -2293,8 +2359,12 @@ func (h *ConfigureInterfacesHandler) renderIPBlock(w http.ResponseWriter, r *htt } } row.JustSaved = true // expand the DHCP foldout to reveal inferred options - // Keep the cfgSaved toast behaviour even though we swap the block. - w.Header().Set("HX-Trigger", `{"cfgSaved":"`+family+` settings saved"}`) + row.SavedMsg = savedMsg + // The block is swapped via outerHTML, so the confirmation is rendered into + // the block footer (above) rather than chased by JS across the swap; the + // log-only event just records it in the Configure activity panel. + msgJSON, _ := json.Marshal(savedMsg) + w.Header().Set("HX-Trigger", `{"cfgLogged":`+string(msgJSON)+`}`) if err := h.Template.ExecuteTemplate(w, fragName, row); err != nil { log.Printf("configure interfaces %s: render %s: %v", name, fragName, err) } diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 6fdbe6f5..37e24315 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -3020,6 +3020,14 @@ details.cfg-fold[open] > summary::before { transform: rotate(90deg); } background: var(--border-subtle); color: var(--primary); } +/* Currently-selected node: set on click and when arriving via a status-page + "Configure →" deep link, so the left tree shows where the right pane is. */ +.yt-label.yt-active { + background: var(--border-subtle); + color: var(--primary); + font-weight: 600; + box-shadow: inset 2px 0 0 var(--primary); +} /* Chevron rotates on open */ .yt-chevron { diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 19d9425e..6b5da539 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -1679,20 +1679,33 @@ // ─── Confirm dialog ──────────────────────────────────────────────────────── // openModal(message, onConfirm) shows the shared and calls onConfirm // if the user clicks Confirm, or does nothing on Cancel. -function openModal(message, onConfirm) { +// openModal shows the shared confirm dialog. With opts.alert it becomes a +// single-button alert (one "Dismiss", no Cancel) for errors the user can only +// acknowledge — e.g. a rejected Apply. The Cancel button and OK label are +// restored on close so the dialog returns to confirm() defaults. +function openModal(message, onConfirm, opts) { + opts = opts || {}; var dlg = document.getElementById('confirm-dialog'); + if (!dlg) { // fallback if dialog missing + if (opts.alert) window.alert(message); + else if (onConfirm) onConfirm(); + return; + } var msg = document.getElementById('dialog-message'); var ok = document.getElementById('dialog-confirm'); var no = document.getElementById('dialog-cancel'); - if (!dlg) { onConfirm(); return; } // fallback if dialog missing msg.textContent = message; + var okLabel = ok.textContent; + if (opts.alert) { no.hidden = true; ok.textContent = 'Dismiss'; } + function cleanup() { ok.removeEventListener('click', handleOK); no.removeEventListener('click', handleNo); + if (opts.alert) { no.hidden = false; ok.textContent = okLabel; } dlg.close(); } - function handleOK() { cleanup(); onConfirm(); } + function handleOK() { cleanup(); if (onConfirm) onConfirm(); } function handleNo() { cleanup(); } ok.addEventListener('click', handleOK); @@ -1878,6 +1891,89 @@ function openModal(message, onConfirm) { }, true); })(); +// ─── YANG tree: selected-node highlight + deep-link reveal ────────────────── +// Two related orientation fixes for the tree editor: +// 1. Clicking a node loads it in the right pane but left no cue of where you +// are — mark the clicked summary .yt-active. +// 2. A status-page "Configure →" deep link (/configure/tree?path=…) only +// filled the right pane, leaving the left tree collapsed and the user +// disoriented. Walk the ancestor chain, opening each
    in turn — +// children load lazily over htmx on toggle, so wait for each level's swap +// before descending — then highlight and scroll the target into view. +(function() { + function setActive(summary) { + document.querySelectorAll('.yt-label.yt-active').forEach(function (s) { + if (s !== summary) s.classList.remove('yt-active'); + }); + if (summary) summary.classList.add('yt-active'); + } + + document.addEventListener('click', function (e) { + var summary = e.target.closest && e.target.closest('summary[data-yang-path]'); + if (summary) setActive(summary); + }); + + // Load a node's children into its .yt-children with a direct htmx.ajax + // request, resolving with the children
      . We deliberately do NOT route + // through the node's lazy "toggle once" trigger: the toggle event from a + // programmatic .open is async and, on a freshly htmx-swapped tree, did not + // reliably reach htmx — the node opened but its body stayed empty until a + // manual reload. htmx.ajax sidesteps all of that. + function loadChildren(details) { + var childUl = details.querySelector(':scope > .yt-children'); + if (!childUl || childUl.children.length) return Promise.resolve(childUl); + var url = details.getAttribute('hx-get'); + if (!url || !window.htmx || !window.htmx.ajax) return Promise.resolve(childUl); + return window.htmx.ajax('GET', url, { target: childUl, swap: 'innerHTML' }) + .then(function () { return childUl; }); + } + + function step(ul, target) { + var best = null; + ul.querySelectorAll(':scope > li > details.yt-node > summary[data-yang-path]').forEach(function (s) { + var p = s.getAttribute('data-yang-path'); + if (p === target || target.indexOf(p + '/') === 0) { + if (!best || p.length > best.getAttribute('data-yang-path').length) best = s; + } + }); + if (!best) return; + + var details = best.parentElement; + var isTarget = best.getAttribute('data-yang-path') === target; + details.open = true; // chevron + reveal the children area + + if (isTarget) { + // Highlight and scroll right away — neither depends on the child load — + // then pull in the target's own children so its subtree shows. + setActive(best); + best.scrollIntoView({ block: 'center' }); + loadChildren(details); + return; + } + // Ancestor: load its children, then descend toward the target. + loadChildren(details).then(function (childUl) { + if (childUl) step(childUl, target); + }); + } + + function initReveal() { + var tree = document.querySelector('.yang-tree[data-initial-path]'); + if (!tree) return; + var target = tree.getAttribute('data-initial-path'); + tree.removeAttribute('data-initial-path'); // process once + var rootChildren = tree.querySelector('.yt-children'); + if (!target || !rootChildren) return; + // Defer a tick: on an htmx navigation the tree was just swapped in and htmx + // is still settling, so a child load triggered now is dropped (the node + // then highlights only after a manual reload). On a full reload it's + // already settled, so the tick is harmless. + setTimeout(function () { step(rootChildren, target); }, 0); + } + + document.addEventListener('DOMContentLoaded', initReveal); + document.addEventListener('htmx:afterSwap', initReveal); +})(); + // ─── ⓘ field-info tooltip (position:fixed to escape overflow clipping) ─────── (function() { var tip = null; @@ -2109,6 +2205,28 @@ function renderCfgLog() { }); })(); + // cfgLogged records a save in the activity panel only — used when the + // handler swaps the whole block via outerHTML and renders its own inline + // confirmation, so there's no span for the page JS to chase. + document.addEventListener('cfgLogged', function(e) { + cfgLog('ok', e.detail && e.detail.value ? e.detail.value : 'Saved'); + }); + + // cfgApplyError fires when Apply / Apply & Save reaches the device but the + // candidate is rejected (e.g. deleting a still-referenced interface). The + // backend returns 200 + this trigger instead of 502 so the connection + // monitor stays quiet — the box is fine, the config isn't. The toolbar + // optimistically logged success before issuing the request; drop that entry + // and show the real reason. + document.addEventListener('cfgApplyError', function(e) { + var msg = (e.detail && e.detail.value) || 'The device rejected the configuration.'; + if (cfgLogEntries.length && cfgLogEntries[cfgLogEntries.length - 1].level === 'ok') { + cfgLogEntries.pop(); + } + cfgLog('error', msg); + openModal('Configuration not applied: ' + msg, null, { alert: true }); + }); + document.addEventListener('cfgSaved', function(e) { var msg = e.detail && e.detail.value ? e.detail.value : 'Saved'; cfgLog('ok', msg); diff --git a/src/webui/templates/pages/configure-interfaces.html b/src/webui/templates/pages/configure-interfaces.html index 366caa92..6f175a7e 100644 --- a/src/webui/templates/pages/configure-interfaces.html +++ b/src/webui/templates/pages/configure-interfaces.html @@ -1724,7 +1724,7 @@
    {{end}} @@ -1852,7 +1852,7 @@
    {{end}} \ No newline at end of file diff --git a/src/webui/templates/pages/containers.html b/src/webui/templates/pages/containers.html index d7a3496a..df6abe27 100644 --- a/src/webui/templates/pages/containers.html +++ b/src/webui/templates/pages/containers.html @@ -5,7 +5,13 @@ {{if .Containers}}
    -
    Containers
    +
    Containers + Configure → +
    @@ -45,7 +51,13 @@ {{else if not .Error}}
    -
    Containers
    +
    Containers + Configure → +

    No containers configured.

    {{end}} diff --git a/src/webui/templates/pages/firewall.html b/src/webui/templates/pages/firewall.html index 701ffee5..41701d20 100644 --- a/src/webui/templates/pages/firewall.html +++ b/src/webui/templates/pages/firewall.html @@ -10,7 +10,13 @@
    -
    Firewall Status
    +
    Firewall Status + Configure → +
    diff --git a/src/webui/templates/pages/interfaces.html b/src/webui/templates/pages/interfaces.html index 7892771e..a64789a5 100644 --- a/src/webui/templates/pages/interfaces.html +++ b/src/webui/templates/pages/interfaces.html @@ -9,7 +9,13 @@ {{if .Interfaces}}
    -
    Interfaces
    +
    Interfaces + Configure → +
    Firewall
    @@ -43,7 +49,13 @@ {{else if not .Error}}
    -
    Interfaces
    +
    Interfaces + Configure → +

    No interfaces found.

    {{end}} diff --git a/src/webui/templates/pages/mdns.html b/src/webui/templates/pages/mdns.html index c4a5d124..64770fff 100644 --- a/src/webui/templates/pages/mdns.html +++ b/src/webui/templates/pages/mdns.html @@ -7,77 +7,68 @@
    {{.Error}}
    {{end}} -
    +
    +
    mDNS + Configure → +
    +
    + + + + + + {{if .Allow}}{{end}} + {{if .Deny}}{{end}} + {{if .Reflector}} + + + + + {{end}} +
    Status + + {{.EnabledText}} +
    Domain{{.Domain}}
    Allow{{.Allow}}
    Deny{{.Deny}}
    Reflector Active{{if .SvcFilter}} — filter: {{.SvcFilter}}{{end}}
    -
    -
    mDNS Status - Configure → -
    - - - - - - - {{if .Allow}}{{end}} - {{if .Deny}}{{end}} - {{if .Reflector}} - - - - - {{end}} +

    Neighbors

    + {{if .Neighbors}} +
    +
    mDNS - - {{.EnabledText}} -
    Domain{{.Domain}}
    Allow{{.Allow}}
    Deny{{.Deny}}
    Reflector Active{{if .SvcFilter}} — filter: {{.SvcFilter}}{{end}}
    + + + + + + + + + + {{range .Neighbors}} + {{$host := .Hostname}} + + + + + + + {{range .ExtraAddrs}} + + + + + + + {{end}} + {{end}} +
    HostnameAddressLast SeenServices
    {{if .ExtraAddrs}} {{end}}{{.Hostname}}{{.PrimaryAddr}}{{.LastSeen}}{{range $i, $svc := .Services}}{{if $i}} {{end}}{{if $svc.URL}}{{$svc.Label}}{{else}}{{$svc.Label}}({{$svc.Port}}){{end}}{{end}}
    {{.}}
    - - {{if .Neighbors}} -
    -
    Neighbors
    -
    - - - - - - - - - - - {{range .Neighbors}} - {{$host := .Hostname}} - - - - - - - {{range .ExtraAddrs}} - - - - - - - {{end}} - {{end}} - -
    HostnameAddressLast SeenServices
    {{if .ExtraAddrs}} {{end}}{{.Hostname}}{{.PrimaryAddr}}{{.LastSeen}}{{range $i, $svc := .Services}}{{if $i}} {{end}}{{if $svc.URL}}{{$svc.Label}}{{else}}{{$svc.Label}}({{$svc.Port}}){{end}}{{end}}
    {{.}}
    -
    -
    - {{else if not .Error}} -
    -
    Neighbors
    -

    No mDNS neighbors discovered.

    -
    + {{else}} +

    No mDNS neighbors discovered.

    {{end}} -
    {{end}} diff --git a/src/webui/templates/pages/ntp.html b/src/webui/templates/pages/ntp.html index 17934c79..4d24f7b6 100644 --- a/src/webui/templates/pages/ntp.html +++ b/src/webui/templates/pages/ntp.html @@ -9,7 +9,13 @@ {{if .NTP}}
    -

    NTP

    +
    NTP + Configure → +
    @@ -69,8 +75,8 @@ {{else if not .Error}}
    NTP - Configure → diff --git a/src/webui/templates/pages/vpn.html b/src/webui/templates/pages/vpn.html index b272d58c..e1f0fde6 100644 --- a/src/webui/templates/pages/vpn.html +++ b/src/webui/templates/pages/vpn.html @@ -11,6 +11,11 @@
    {{.Name}}{{if .ListenPort}} :{{.ListenPort}}{{end}} + Configure →
    {{if .Addresses}}
    @@ -51,7 +56,13 @@ {{end}} {{else if not .Error}}
    -
    WireGuard
    +
    WireGuard + Configure → +

    No WireGuard tunnels configured.

    {{end}} diff --git a/src/webui/templates/pages/wifi.html b/src/webui/templates/pages/wifi.html index 58b48553..b6d8a3bd 100644 --- a/src/webui/templates/pages/wifi.html +++ b/src/webui/templates/pages/wifi.html @@ -15,6 +15,11 @@
    {{.Name}}
    + Configure →
    Sync Status
    diff --git a/src/webui/templates/pages/yang-tree.html b/src/webui/templates/pages/yang-tree.html index a21009e4..3da28bdd 100644 --- a/src/webui/templates/pages/yang-tree.html +++ b/src/webui/templates/pages/yang-tree.html @@ -18,7 +18,7 @@ {{/* ── Left pane: navigation tree ───────────────────────── */}}
    {{if .ReadOnly}}View all{{else}}Edit all{{end}}
    -
      +
      • From 7e0e6f244df36cf388efba01e71630738489117b Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 15 Jun 2026 17:47:32 +0200 Subject: [PATCH 56/59] webui: user-menu Auto-logout and theme fixes Auto-logout was client-side only while the server kept a fixed 1h idle timeout, so "Off" still logged you out. Sessions now carry a per-session timeout the client syncs from the menu (0 = never), capped at 24h. Theme: the login toggle icon was never initialised on load (stuck on the 'auto' glyph); add color-scheme so native controls follow the OS on 'auto', and re-apply on OS theme change. Signed-off-by: Joachim Wiberg --- src/webui/internal/auth/login.go | 20 ++++++++++++++++++ src/webui/internal/auth/store.go | 32 +++++++++++++++++++++++++---- src/webui/internal/server/server.go | 1 + src/webui/static/css/style.css | 4 ++++ src/webui/static/js/app.js | 29 ++++++++++++++++++++++++-- 5 files changed, 80 insertions(+), 6 deletions(-) diff --git a/src/webui/internal/auth/login.go b/src/webui/internal/auth/login.go index 733445a4..75b61f67 100644 --- a/src/webui/internal/auth/login.go +++ b/src/webui/internal/auth/login.go @@ -8,6 +8,8 @@ import ( "html/template" "log" "net/http" + "strconv" + "time" "infix/webui/internal/handlers" "infix/webui/internal/restconf" @@ -129,6 +131,24 @@ func (h *LoginHandler) DoLogout(w http.ResponseWriter, r *http.Request) { fullRedirect(w, r, "/login") } +// SetSessionTimeout updates the current session's idle timeout from the +// client's Auto-logout menu. The "timeout" form value is seconds; 0 means +// never expire ("Off"). The store caps the upper bound. +func (h *LoginHandler) SetSessionTimeout(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(cookieName) + if err != nil { + http.Error(w, "no session", http.StatusUnauthorized) + return + } + secs, err := strconv.Atoi(r.FormValue("timeout")) + if err != nil || secs < 0 { + http.Error(w, "bad timeout", http.StatusBadRequest) + return + } + h.Store.SetTimeout(c.Value, time.Duration(secs)*time.Second) + w.WriteHeader(http.StatusNoContent) +} + // fullRedirect forces a full page navigation. When the request comes // from htmx (boosted form) we use HX-Redirect so the browser does a // real page load instead of an AJAX swap — this is essential for the diff --git a/src/webui/internal/auth/store.go b/src/webui/internal/auth/store.go index a7231217..ef7b9109 100644 --- a/src/webui/internal/auth/store.go +++ b/src/webui/internal/auth/store.go @@ -11,6 +11,7 @@ import ( ) const sessionTimeout = 1 * time.Hour +const maxSessionTimeout = 24 * time.Hour // sessionEntry is the in-memory state of a single authenticated // session. The user's password is kept alongside the token so the @@ -22,6 +23,13 @@ type sessionEntry struct { csrfToken string features map[string]bool lastSeenAt time.Time + timeout time.Duration // idle timeout; 0 = never expire ("Off") +} + +// expired reports whether an entry has passed its idle timeout. A zero +// timeout means the session never idle-expires — the user chose "Off". +func expired(e *sessionEntry) bool { + return e.timeout > 0 && time.Since(e.lastSeenAt) > e.timeout } // SessionStore issues opaque random session tokens backed by an @@ -62,6 +70,7 @@ func (s *SessionStore) Create(username, password string, features map[string]boo csrfToken: csrf, features: features, lastSeenAt: time.Now(), + timeout: sessionTimeout, } s.mu.Unlock() return token, csrf, nil @@ -73,7 +82,7 @@ func (s *SessionStore) Create(username, password string, features map[string]boo func (s *SessionStore) Lookup(token string) (username, password, csrf string, features map[string]bool, ok bool) { s.mu.RLock() e, present := s.sessions[token] - if present && time.Since(e.lastSeenAt) <= sessionTimeout { + if present && !expired(e) { username, password, csrf, features = e.username, e.password, e.csrfToken, e.features s.mu.RUnlock() return username, password, csrf, features, true @@ -86,7 +95,7 @@ func (s *SessionStore) Lookup(token string) (username, password, csrf string, fe // expired. if present { s.mu.Lock() - if e, ok := s.sessions[token]; ok && time.Since(e.lastSeenAt) > sessionTimeout { + if e, ok := s.sessions[token]; ok && expired(e) { delete(s.sessions, token) } s.mu.Unlock() @@ -94,6 +103,22 @@ func (s *SessionStore) Lookup(token string) (username, password, csrf string, fe return "", "", "", nil, false } +// SetTimeout updates a session's idle timeout (0 = never expire) so the +// server-side expiry tracks the client's Auto-logout menu. Out-of-range +// values — including a negative from integer overflow on a garbage request — +// are capped at maxSessionTimeout so a session can't be pinned open +// indefinitely; 0 still means never. No-op if the token is unknown. +func (s *SessionStore) SetTimeout(token string, d time.Duration) { + if d < 0 || d > maxSessionTimeout { + d = maxSessionTimeout + } + s.mu.Lock() + if e, ok := s.sessions[token]; ok { + e.timeout = d + } + s.mu.Unlock() +} + // Refresh extends a session's lifetime by resetting its last-seen // timestamp. Called by the auth middleware on each user-driven // request so an active session doesn't expire mid-use. No-op if the @@ -118,10 +143,9 @@ func (s *SessionStore) janitor() { t := time.NewTicker(1 * time.Minute) defer t.Stop() for range t.C { - cutoff := time.Now().Add(-sessionTimeout) s.mu.Lock() for token, e := range s.sessions { - if e.lastSeenAt.Before(cutoff) { + if expired(e) { delete(s.sessions, token) } } diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index c565c13c..63e54b0e 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -297,6 +297,7 @@ func New( mux.HandleFunc("GET /login", login.ShowLogin) mux.HandleFunc("POST /login", login.DoLogin) mux.HandleFunc("POST /logout", login.DoLogout) + mux.HandleFunc("POST /session/timeout", login.SetSessionTimeout) // Static assets (public). staticServer := http.FileServerFS(staticFS) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 37e24315..880bb59a 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -3,6 +3,10 @@ ========================================================================== */ :root { + /* Native controls (scrollbars, inputs, date pickers) follow the OS + light/dark preference while on 'auto'; .dark/.light pin it explicitly. */ + color-scheme: light dark; + /* --- Primitives: Slate (Neutral) --- */ --slate-50: #f8fafc; --slate-100: #f1f5f9; diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 6b5da539..65f5716e 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -1387,9 +1387,20 @@ // Apply saved theme immediately (before DOMContentLoaded to avoid flash) applyTheme(getTheme()); + // Follow the OS when on 'auto'. Colours already track via CSS @media + + // color-scheme; re-applying on change keeps the menu indicators and any + // theme-derived state in sync. Ignored when a theme is pinned explicitly. + try { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { + if (!getTheme()) applyTheme(null); + }); + } catch (_) {} + document.addEventListener('DOMContentLoaded', function() { - // Apply checkmarks now that DOM is ready - updateDropdownCheck(getTheme() || 'auto'); + // Re-apply now that the DOM exists: applyTheme() ran in before the + // dropdown and login toggle were parsed, so their indicators weren't set + // (the login toggle was stuck on its default 'auto' glyph). + applyTheme(getTheme()); // Dropdown theme options (main app) document.querySelectorAll('.theme-opt').forEach(function(btn) { @@ -2303,6 +2314,18 @@ function renderCfgLog() { if (ms > 0) timerId = setTimeout(doLogout, ms); } + // Mirror the chosen idle timeout to the server so the server-side session + // expiry matches the menu — including "Off" (0 = never). The server starts + // each session at its 1 h default; this re-asserts the user's preference on + // load and whenever they change it. + function syncServerTimeout(secs) { + var fd = new FormData(); + fd.append('timeout', secs); + var meta = document.querySelector('meta[name="csrf-token"]'); + if (meta) fd.append('csrf', meta.getAttribute('content') || ''); + fetch('/session/timeout', { method: 'POST', body: fd, credentials: 'same-origin' }).catch(function () {}); + } + // Background pollers (watchdog, *counters refresh) must NOT extend // the idle window — otherwise the timer never reaches the threshold. function isPollingPath(p) { @@ -2314,6 +2337,7 @@ function renderCfgLog() { updateOpts(); reset(); + syncServerTimeout(localStorage.getItem(LS_KEY) || DEFAULT); // mousemove deliberately omitted: trackpad jitter from a neighbouring // window would keep the session alive indefinitely. @@ -2332,6 +2356,7 @@ function renderCfgLog() { localStorage.setItem(LS_KEY, btn.getAttribute('data-timeout')); updateOpts(); reset(); + syncServerTimeout(btn.getAttribute('data-timeout')); }); // Session expired server-side while we were idle: any HX request From 7e19ee877730c9238f184de383d4b43d9cc9ba8f Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 15 Jun 2026 18:53:06 +0200 Subject: [PATCH 57/59] webui: add Connectivity and Addresses cards to Overview Surface network diagnostics the Overview lacked: default gateway(s) with a live reachability dot, an Internet reachability check, the effective DNS resolvers (origin static vs DHCP, plus the interface), search domains, NTP sync, and the IP addresses of every L3 interface. All from the operational datastore. Reachability is an async per-address ping (pulsing green dot on reply, red cross on none). Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/dashboard.go | 190 ++++++++++++++++++++++- src/webui/internal/server/server.go | 1 + src/webui/static/css/style.css | 25 +++ src/webui/templates/pages/dashboard.html | 52 +++++++ 4 files changed, 266 insertions(+), 2 deletions(-) diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go index 6af9d739..5c1c0e08 100644 --- a/src/webui/internal/handlers/dashboard.go +++ b/src/webui/internal/handlers/dashboard.go @@ -9,7 +9,9 @@ import ( "html/template" "log" "math" + "net" "net/http" + "os/exec" "strconv" "strings" "sync" @@ -95,6 +97,32 @@ type systemState struct { Clock clock `json:"clock"` Software software `json:"infix-system:software"` Resource resourceUsage `json:"infix-system:resource-usage"` + DNS *dnsResolver `json:"infix-system:dns-resolver"` + NTP *ntpSources `json:"infix-system:ntp"` +} + +// dnsResolver mirrors the effective resolver state — servers carry an +// origin (static vs dhcp) and, for dhcp, the interface they arrived on. +type dnsResolver struct { + Server []dnsServer `json:"server"` + Search []string `json:"search"` +} + +type dnsServer struct { + Address string `json:"address"` + Origin string `json:"origin"` + Interface string `json:"interface"` +} + +type ntpSources struct { + Sources struct { + Source []ntpSource `json:"source"` + } `json:"sources"` +} + +type ntpSource struct { + Address string `json:"address"` + State string `json:"state"` } type platform struct { @@ -218,7 +246,28 @@ type dashboardData struct { Disks []diskEntry Board boardInfo KeyVitals []sensorEntry // Overview's at-a-glance subset: CPU/SoC + wifi-radio temperatures and fan RPMs. Status > Hardware has the full inventory. - Error string + // Connectivity card. + Gateways []gatewayEntry + InternetProbe string // address pinged for the Internet reachability row + DNSServers []dnsServer + DNSSearch []string + NTPSync string // "" / the selected NTP source address + // Addresses card. + Addresses []ifaceAddrEntry + Error string +} + +// gatewayEntry is a default route's next-hop. +type gatewayEntry struct { + Addr string + Iface string +} + +// ifaceAddrEntry is one L3 interface's addresses for the Addresses card. +type ifaceAddrEntry struct { + Name string + Addrs []string + Up bool } type boardInfo struct { @@ -252,6 +301,10 @@ type diskEntry struct { ReadOnly bool } +// internetProbe is the address the Connectivity card pings for its Internet +// reachability row — a well-known, stable anycast resolver. +const internetProbe = "1.1.1.1" + // DashboardHandler serves the main dashboard page. type DashboardHandler struct { Template *template.Template @@ -278,11 +331,13 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { Location string `json:"location"` } `json:"ietf-system:system"` } + ifaces interfacesWrapper + routes ribWrapper stateErr, hwErr, confErr error wg sync.WaitGroup ) - wg.Add(3) + wg.Add(5) go func() { defer wg.Done() stateErr = h.RC.Get(ctx, "/data/ietf-system:system-state", &state) @@ -295,6 +350,20 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { defer wg.Done() confErr = h.RC.Get(ctx, "/data/ietf-system:system", &sysConf) }() + // Connectivity/Addresses cards are best-effort: a failure here logs but + // doesn't fault the whole dashboard, so the card simply renders empty. + go func() { + defer wg.Done() + if err := h.RC.Get(ctx, "/data/ietf-interfaces:interfaces", &ifaces); err != nil { + log.Printf("restconf interfaces: %v", err) + } + }() + go func() { + defer wg.Done() + if err := h.RC.Get(ctx, "/data/ietf-routing:routing", &routes); err != nil { + log.Printf("restconf routing: %v", err) + } + }() wg.Wait() if stateErr != nil { @@ -413,6 +482,25 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { data.Location = sysConf.System.Location } + // Connectivity & Addresses cards (best-effort, independent of the above). + data.Gateways = defaultGateways(routes) + data.InternetProbe = internetProbe + if stateErr == nil { + if dns := state.SystemState.DNS; dns != nil { + data.DNSServers = dns.Server + data.DNSSearch = dns.Search + } + if ntp := state.SystemState.NTP; ntp != nil { + for _, s := range ntp.Sources.Source { + if s.State == "selected" { + data.NTPSync = s.Address + break + } + } + } + } + data.Addresses = ifaceAddresses(ifaces) + tmplName := "dashboard.html" if r.Header.Get("HX-Request") == "true" { tmplName = "content" @@ -423,6 +511,104 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { } } +// defaultGateways extracts the installed default-route next-hops (v4 and v6). +func defaultGateways(rw ribWrapper) []gatewayEntry { + var ribs []ribJSON + if rw.Routing != nil { + ribs = rw.Routing.Ribs.Rib + } else if rw.Ribs != nil { + ribs = rw.Ribs.Rib + } + var gws []gatewayEntry + for _, rib := range ribs { + for _, rt := range rib.Routes.Route { + if rt.Active == nil { + continue // only installed routes + } + switch rt.destinationPrefix() { + case "0.0.0.0/0", "::/0": + default: + continue + } + iface, addr := rt.NextHop.resolve() + if addr == "" { + continue + } + gws = append(gws, gatewayEntry{Addr: addr, Iface: iface}) + } + } + return gws +} + +// ifaceAddresses lists every interface carrying an IP address, loopback last. +func ifaceAddresses(iw interfacesWrapper) []ifaceAddrEntry { + var l3, lo []ifaceAddrEntry + for _, ifc := range iw.Interfaces.Interface { + var addrs []string + for _, ip := range []*ipCfg{ifc.IPv4, ifc.IPv6} { + if ip == nil { + continue + } + for _, a := range ip.Address { + addrs = append(addrs, fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength))) + } + } + if len(addrs) == 0 { + continue + } + e := ifaceAddrEntry{Name: ifc.Name, Addrs: addrs, Up: ifc.OperStatus == "up"} + if prettyIfType(ifc.Type) == ifTypeLoopback { + lo = append(lo, e) + } else { + l3 = append(l3, e) + } + } + return append(l3, lo...) +} + +// Reachability pings an address and returns a tiny indicator — a pulsing green +// dot when it replies, a red ✗ otherwise. Connectivity-card slots load it +// async (hx-trigger="load") so the dashboard render isn't blocked on a probe. +// The target is validated as a literal IP so the ping argument can never become +// an arbitrary host; a link-local IPv6 is scoped with its egress interface. +func (h *DashboardHandler) Reachability(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + ip := r.URL.Query().Get("ip") + addr := net.ParseIP(ip) + if addr == nil { + fmt.Fprint(w, ``) + return + } + target := ip + if iface := r.URL.Query().Get("iface"); iface != "" && addr.IsLinkLocalUnicast() && validZone(iface) { + target = ip + "%" + iface + } + + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + if err := exec.CommandContext(ctx, "ping", "-c", "1", "-W", "1", target).Run(); err != nil { + fmt.Fprintf(w, ``, + template.HTMLEscapeString(ip)) + return + } + fmt.Fprintf(w, ``, + template.HTMLEscapeString(ip)) +} + +// validZone guards the IPv6 zone (interface name) passed to ping: a plain +// interface name, no shell metacharacters (exec args aren't shell-parsed, but +// keep it tight). +func validZone(s string) bool { + for _, c := range s { + ok := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || + c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-' + if !ok { + return false + } + } + return s != "" +} + // softwareVersion returns the version string for the booted software slot. func softwareVersion(sw software) string { for _, slot := range sw.Slot { diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 63e54b0e..0db03db6 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -305,6 +305,7 @@ func New( // Authenticated routes. mux.HandleFunc("GET /{$}", dash.Index) + mux.HandleFunc("GET /dashboard/reachability", dash.Reachability) mux.HandleFunc("GET /interfaces", iface.Overview) mux.HandleFunc("GET /interfaces/{name}", iface.Detail) mux.HandleFunc("GET /interfaces/{name}/counters", iface.Counters) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 880bb59a..e63cb44e 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -2085,6 +2085,31 @@ details[open] > .cfg-multi-summary { /* Keystore */ .empty-message { color: var(--fg-muted); font-style: italic; padding: 1rem; text-align: center; } +/* Overview Connectivity card. Reach indicators reuse .status-dot for shape; + these add the neutral "pending" colour, a reply pulse, and a colour-blind-safe + ✗ (shape, not just colour). */ +.dns-origin { color: var(--fg-muted); font-size: 0.8rem; } +.reach-pending { background: var(--slate-300); } +.dark .reach-pending { background: var(--slate-600); } +.reach-pulse { animation: reach-pulse 1.6s ease-in-out infinite; } +@keyframes reach-pulse { + 0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5); } + 70% { box-shadow: 0 0 0 5px rgba(34, 197, 94, 0); } + 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } +} +.reach-x { + display: inline-block; + width: 8px; + margin-right: 0.5rem; + text-align: center; + vertical-align: middle; + color: var(--danger); + font-weight: 700; +} +@media (prefers-reduced-motion: reduce) { + .reach-pulse { animation: none; } +} + .key-row-toggle { background: none; border: none; diff --git a/src/webui/templates/pages/dashboard.html b/src/webui/templates/pages/dashboard.html index 505a76d9..7f70c8b7 100644 --- a/src/webui/templates/pages/dashboard.html +++ b/src/webui/templates/pages/dashboard.html @@ -101,5 +101,57 @@ {{end}}
    {{end}} + +
    +
    Connectivity
    +
    +
    + + + + + + + + + + + + + {{if .DNSSearch}} + + {{end}} + {{if .NTPSync}} + + {{end}} +
    Gateway + {{range .Gateways}}
    {{.Addr}}{{if .Iface}} via {{.Iface}}{{end}}
    {{else}}none{{end}} +
    Internet{{.InternetProbe}}
    DNS + {{range .DNSServers}}
    {{.Address}} {{if eq .Origin "dhcp"}}DHCP{{if .Interface}} · {{.Interface}}{{end}}{{else}}{{.Origin}}{{end}}
    {{else}}none{{end}} +
    Search{{range $i, $s := .DNSSearch}}{{if $i}}, {{end}}{{$s}}{{end}}
    Timesynced · {{.NTPSync}}
    +
    +
    + + {{if .Addresses}} +
    +
    Addresses + All interfaces → +
    +
    + + {{range .Addresses}} + + + + + {{end}} +
    {{.Name}}{{range $i, $a := .Addrs}}{{if $i}}
    {{end}}{{$a}}{{end}}
    +
    +
    + {{end}} {{end}} From f5c549f66985c8ec8c19077f3f52538f6cdf7117 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 8 Jun 2026 10:43:30 +0200 Subject: [PATCH 58/59] test: Add test for login flow in WebUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify: - Unauthenticated GET / -> 303 to /login - Bad-password rejection -> no session is created - Session cookie is flagged HttpOnly - Authenticated GET /interfaces -> 200 - with htmx-config meta tag check - State-changing POST without CSRF token -> 403 - POST with X-CSRF-Token header -> succeeds - After logout the session no longer grants access The cookie-preserving WebUI helper class also folds in the IPv6 link-local zone-id workaround (re-encode %25 back to %) that infamy.restconf already uses. Signed-off-by: Mattias Walström --- test/case/services/all.yaml | 3 + test/case/services/webui/all.yaml | 3 + .../services/webui/login_csrf/Readme.adoc | 1 + test/case/services/webui/login_csrf/test.adoc | 45 +++++ test/case/services/webui/login_csrf/test.py | 170 ++++++++++++++++++ .../services/webui/login_csrf/topology.dot | 22 +++ .../services/webui/login_csrf/topology.svg | 33 ++++ test/case/webui/basic/test.py | 86 +++++++++ 8 files changed, 363 insertions(+) create mode 100644 test/case/services/webui/all.yaml create mode 120000 test/case/services/webui/login_csrf/Readme.adoc create mode 100644 test/case/services/webui/login_csrf/test.adoc create mode 100755 test/case/services/webui/login_csrf/test.py create mode 100644 test/case/services/webui/login_csrf/topology.dot create mode 100644 test/case/services/webui/login_csrf/topology.svg create mode 100644 test/case/webui/basic/test.py diff --git a/test/case/services/all.yaml b/test/case/services/all.yaml index eef4de21..155de1b2 100644 --- a/test/case/services/all.yaml +++ b/test/case/services/all.yaml @@ -7,3 +7,6 @@ - name: SSH suite: ssh/all.yaml + +- name: Web UI + suite: webui/all.yaml diff --git a/test/case/services/webui/all.yaml b/test/case/services/webui/all.yaml new file mode 100644 index 00000000..951e6b02 --- /dev/null +++ b/test/case/services/webui/all.yaml @@ -0,0 +1,3 @@ +--- +- name: Web UI Login and CSRF Protection + case: login_csrf/test.py diff --git a/test/case/services/webui/login_csrf/Readme.adoc b/test/case/services/webui/login_csrf/Readme.adoc new file mode 120000 index 00000000..ae32c841 --- /dev/null +++ b/test/case/services/webui/login_csrf/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/services/webui/login_csrf/test.adoc b/test/case/services/webui/login_csrf/test.adoc new file mode 100644 index 00000000..b47943a2 --- /dev/null +++ b/test/case/services/webui/login_csrf/test.adoc @@ -0,0 +1,45 @@ +=== Web UI Login and CSRF Protection + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/services/webui/login_csrf] + +==== Description + +Black-box test of the web management interface (nginx :443 -> Go webui on +127.0.0.1:10000). Drives the UI exactly like a browser would, using a +cookie-preserving HTTP session, and verifies the security-critical parts of +the login flow: + +1. A protected page is not served to an unauthenticated client; the request + is redirected to /login. +2. Login with a bad password is rejected (no session is created). +3. Login with the correct credentials establishes a session, and the session + cookie is flagged HttpOnly. +4. An authenticated page load succeeds. +5. A state-changing POST without the CSRF token is rejected with 403, even + with a valid session cookie. +6. The same POST with the CSRF token (X-CSRF-Token header) succeeds. +7. After logout the session cookie no longer grants access. + +The device is reached over its IPv6 link-local mgmt address, which carries a +zone id (fe80::.../%ifname). requests/urllib3 percent-encode the '%' to +'%25' while preparing the request, so we substitute it back before sending — +the same workaround infamy.restconf uses. + +==== Topology + +image::topology.svg[Web UI Login and CSRF Protection topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUT +. Wait for the web UI to come up +. Unauthenticated access to a protected page redirects to /login +. Login with a bad password is rejected +. Login with correct credentials establishes a session +. Session cookie is flagged HttpOnly +. Authenticated page load succeeds +. State-changing POST without CSRF token is rejected +. State-changing POST with CSRF token succeeds (logout) +. After logout the session no longer grants access + + diff --git a/test/case/services/webui/login_csrf/test.py b/test/case/services/webui/login_csrf/test.py new file mode 100755 index 00000000..27c843b3 --- /dev/null +++ b/test/case/services/webui/login_csrf/test.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Web UI login and CSRF protection + +Black-box test of the web management interface (nginx :443 -> Go webui on +127.0.0.1:10000). Drives the UI exactly like a browser would, using a +cookie-preserving HTTP session, and verifies the security-critical parts of +the login flow: + +1. A protected page is not served to an unauthenticated client; the request + is redirected to /login. +2. Login with a bad password is rejected (no session is created). +3. Login with the correct credentials establishes a session, and the session + cookie is flagged HttpOnly. +4. An authenticated page load succeeds. +5. A state-changing POST without the CSRF token is rejected with 403, even + with a valid session cookie. +6. The same POST with the CSRF token (X-CSRF-Token header) succeeds. +7. After logout the session cookie no longer grants access. + +The device is reached over its IPv6 link-local mgmt address, which carries a +zone id (fe80::.../%ifname). requests/urllib3 percent-encode the '%' to +'%25' while preparing the request, so we substitute it back before sending — +the same workaround infamy.restconf uses. +""" +import re +import warnings + +import requests +from urllib3.exceptions import InsecureRequestWarning + +import infamy +from infamy import until + +# The device uses a self-signed certificate; silence the verify=False noise. +warnings.simplefilter("ignore", InsecureRequestWarning) + + +class WebUI: + """Cookie-preserving HTTP client for the web UI, link-local aware.""" + + def __init__(self, host, username, password): + self.base = f"https://[{host}]" + self.username = username + self.password = password + self.session = requests.Session() + + def _send(self, method, path, **kwargs): + req = requests.Request(method, f"{self.base}{path}", **kwargs) + prepared = self.session.prepare_request(req) + # Undo urllib3's percent-encoding of the IPv6 zone id separator. + prepared.url = re.sub(r"%25", "%", prepared.url) + # http.cookiejar's host normalisation for IPv6 link-local URLs is + # inconsistent across Python versions — the csrf cookie set on + # GET /login can fail to match the same URL on the following POST, + # producing a 403 from csrfMiddleware that's awfully hard to + # diagnose. Rebuild the Cookie header from the jar directly so + # whatever we received gets sent back verbatim. + jar = "; ".join(f"{c.name}={c.value}" for c in self.session.cookies) + if jar: + prepared.headers["Cookie"] = jar + # Never auto-follow redirects: requests would rebuild the target URL + # internally and re-mangle the link-local zone id, breaking the + # connection. We assert on the 3xx directly instead. + return self.session.send(prepared, verify=False, allow_redirects=False) + + def get(self, path, **kwargs): + return self._send("GET", path, **kwargs) + + def post(self, path, **kwargs): + return self._send("POST", path, **kwargs) + + def csrf_token(self): + """Return the current CSRF token from the session cookie jar.""" + return self.session.cookies.get("csrf") + + def login(self): + """Fetch /login, scrape the CSRF token, and POST credentials. + + Returns the POST response. On success the server answers 303 to / + and sets the session cookie; on failure it re-renders /login (200) + with no session cookie. + """ + page = self.get("/login") + assert page.status_code == 200, f"GET /login: {page.status_code}" + m = re.search(r'name="csrf-token"\s+content="([^"]+)"', page.text) + assert m, "no csrf-token meta tag on /login" + token = m.group(1) + + return self.post("/login", data={ + "username": self.username, + "password": self.password, + "csrf": token, + }) + + +with infamy.Test() as test: + with test.step("Set up topology and attach to target DUT"): + env = infamy.Env() + # Attach over NETCONF only to learn the mgmt address and admin + # password; the web UI itself is exercised over plain HTTPS below. + target = env.attach("target", "mgmt", protocol="netconf") + host = target.location.host + password = target.location.password + + with test.step("Wait for the web UI to come up"): + def webui_ready(): + try: + r = WebUI(host, "admin", password).get("/login") + return r.status_code == 200 and "csrf-token" in r.text + except Exception: + return False + + until(webui_ready, attempts=60, interval=2) + + with test.step("Unauthenticated access to a protected page redirects to /login"): + anon = WebUI(host, "admin", password) + r = anon.get("/") + assert r.status_code == 303, f"want 303, got {r.status_code}" + assert r.headers.get("Location") == "/login", \ + f"want redirect to /login, got {r.headers.get('Location')}" + + with test.step("Login with a bad password is rejected"): + bad = WebUI(host, "admin", "definitely-not-the-password") + r = bad.login() + assert r.status_code == 200, \ + f"bad login should re-render /login (200), got {r.status_code}" + assert "session" not in bad.session.cookies, \ + "session cookie set despite wrong password" + # And a protected page is still denied. + r = bad.get("/") + assert r.status_code == 303, \ + f"bad login still authenticated? got {r.status_code}" + + with test.step("Login with correct credentials establishes a session"): + ui = WebUI(host, "admin", password) + r = ui.login() + assert r.status_code == 303, f"login should redirect (303), got {r.status_code}" + assert r.headers.get("Location") == "/", \ + f"login should redirect to /, got {r.headers.get('Location')}" + assert "session" in ui.session.cookies, "no session cookie after login" + + with test.step("Session cookie is flagged HttpOnly"): + sess = next(c for c in ui.session.cookies if c.name == "session") + assert sess.has_nonstandard_attr("HttpOnly"), \ + "session cookie missing HttpOnly flag" + + with test.step("Authenticated page load succeeds"): + r = ui.get("/interfaces") + assert r.status_code == 200, f"GET /interfaces: {r.status_code}" + # base.html ships an htmx-config meta tag on every authenticated page; + # its absence means the layout chrome didn't render even though we got 200. + assert 'name="htmx-config"' in r.text, "htmx-config meta missing from authenticated page" + + with test.step("State-changing POST without CSRF token is rejected"): + # Valid session cookie, but no X-CSRF-Token / csrf form field. + r = ui.post("/logout") + assert r.status_code == 403, \ + f"CSRF-less POST not rejected, got {r.status_code}" + + with test.step("State-changing POST with CSRF token succeeds (logout)"): + r = ui.post("/logout", headers={"X-CSRF-Token": ui.csrf_token()}) + assert r.status_code in (200, 204, 303), \ + f"logout failed, got {r.status_code}" + + with test.step("After logout the session no longer grants access"): + r = ui.get("/") + assert r.status_code == 303, \ + f"still authenticated after logout, got {r.status_code}" + + test.succeed() diff --git a/test/case/services/webui/login_csrf/topology.dot b/test/case/services/webui/login_csrf/topology.dot new file mode 100644 index 00000000..34a6b58b --- /dev/null +++ b/test/case/services/webui/login_csrf/topology.dot @@ -0,0 +1,22 @@ +graph "1x1" { + layout="neato"; + overlap="false"; + esep="+80"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt }", + pos="0,12!", + requires="controller", + ]; + + target [ + label="{ mgmt } | target", + pos="10,12!", + requires="infix", + ]; + + host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"] +} diff --git a/test/case/services/webui/login_csrf/topology.svg b/test/case/services/webui/login_csrf/topology.svg new file mode 100644 index 00000000..6fc6f47a --- /dev/null +++ b/test/case/services/webui/login_csrf/topology.svg @@ -0,0 +1,33 @@ + + + + + + +1x1 + + + +host + +host + +mgmt + + + +target + +mgmt + +target + + + +host:mgmt--target:mgmt + + + + diff --git a/test/case/webui/basic/test.py b/test/case/webui/basic/test.py new file mode 100644 index 00000000..c8f02687 --- /dev/null +++ b/test/case/webui/basic/test.py @@ -0,0 +1,86 @@ +#!/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 12f1edb435b363ba3b1f1a1bf36c5e7189a8b2d7 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 12 Jun 2026 13:40:56 +0200 Subject: [PATCH 59/59] doc: update ChangeLog Use absolute links so they survive being exported to Release Notes. Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index ad369471..9544e32d 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -4,11 +4,15 @@ Change Log All notable changes to the project are documented in this file. [v26.06.0][UNRELEASED] --------------- +------------------------- ### Changes - Upgrade Linux kernel to 6.18.35 (LTS) +- Add basic web interface: static status pages and a tree view of operational + status. Curated configuration pages for some common tasks and a YANG tree + editor for the rest. Also includes a maintenance section for firmware + upgrade, backup & restore, and more - Add Wi-Fi roaming for fast, seamless handoff between access points that share an SSID: 802.11k, 802.11v and 802.11r (over-the-air FT). See the [Wi-Fi][wifi] guide for details @@ -21,11 +25,15 @@ All notable changes to the project are documented in this file. ### Fixes -- Firewall masquerade no longer enables the global IPv4/IPv6 forwarding - sysctls. You must now enable IP forwarding explicitly on the interfaces - that should route traffic; enabling NAT alone is no longer enough +- Enabling IP masquerading in the firewall no longer enables IP forwarding on + all interfaces. This has been an issue ever since the firewall support was + introduced in v25.10.0 +- Fix file permission regression in `/cfg/startup-config.cfg`, causing the + default `admin` user no permission to read or write the file from shell +- Fix admin url shown for HTTP/HTTPS links in browser, + used pre-conflict resolution hostname.local, instead of hostname-2.local -[wifi]: wifi.md +[wifi]: https://www.kernelkit.org/infix/latest/wifi/ [v26.05.0][] - 2026-05-29 ------------------------- @@ -84,9 +92,9 @@ All notable changes to the project are documented in this file. show`, which were not expanded, so `show interface` and other operational reads failed. Ranges are now expanded and listed correctly -[ethernet]: ethernet.md#restricting-advertised-link-modes +[ethernet]: https://www.kernelkit.org/infix/latest/ethernet/#restricting-advertised-link-modes [BPI-R3]: https://docs.banana-pi.org/en/BPI-R3/BananaPi_BPI-R3 -[AcerConnectVero]: ../board/aarch64/acer-connect-vero-w6m/ +[AcerConnectVero]: https://github.com/kernelkit/infix/tree/main/board/aarch64/acer-connect-vero-w6m [v26.04.0][] - 2026-04-30 ------------------------- @@ -2160,6 +2168,7 @@ Supported YANG models in addition to those used by sysrepo and netopeer: [buildroot]: https://buildroot.org/ [UNRELEASED]: https://github.com/kernelkit/infix/compare/v26.05.0...HEAD +[v26.06.0]: https://github.com/kernelkit/infix/compare/v26.05.0...v26.06.0 [v26.05.0]: https://github.com/kernelkit/infix/compare/v26.04.0...v26.05.0 [v26.04.0]: https://github.com/kernelkit/infix/compare/v26.03.0...v26.04.0 [v26.03.0]: https://github.com/kernelkit/infix/compare/v26.02.0...v26.03.0