diff --git a/board/common/rootfs/etc/default/webui b/board/common/rootfs/etc/default/webui new file mode 100644 index 000000000..ce1dc5a2b --- /dev/null +++ b/board/common/rootfs/etc/default/webui @@ -0,0 +1,5 @@ +RESTCONF_URL=https://127.0.0.1/restconf +INSECURE_TLS=1 +# Spool firmware uploads (and any other temp files) to the eMMC-backed /var/tmp +# instead of the RAM-backed /tmp — a 160 MB .pkg upload otherwise OOM-kills us. +TMPDIR=/var/tmp 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 000000000..01182dc2a --- /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.conf b/board/common/rootfs/etc/nginx/restconf-access.conf new file mode 100644 index 000000000..ec361e7d9 --- /dev/null +++ b/board/common/rootfs/etc/nginx/restconf-access.conf @@ -0,0 +1,3 @@ +allow 127.0.0.1; +allow ::1; +deny all; diff --git a/board/common/rootfs/etc/nginx/restconf.app b/board/common/rootfs/etc/nginx/restconf.app index 7c8caddae..1d2617412 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 2356d6661..3df290fe6 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 d74357e8c..29c611c2a 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 19d8f1120..775393d61 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 d286c3305..a2443ba36 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 30d59e690..b687676fc 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 65f0d7b07..1dbc76ecb 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 2008722ea..077d53a3a 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/doc/ChangeLog.md b/doc/ChangeLog.md index fe3d51dc7..6ea47c12e 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -3,6 +3,20 @@ Change Log All notable changes to the project are documented in this file. + +[v26.06.0][UNRELEASED] +------------------------- + +### Changes + +- Initial WebUI: static status pages and a tree view of operational status. + Curated configuration pages for a some tasks and a tree view for the rest. + Also includes a maintenance section for firmware upgrade and more + +### Fixes + +N/A + [v26.05.0][] - 2026-05-29 ------------------------- diff --git a/package/Config.in b/package/Config.in index 110d247b2..6998f5804 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 6a18d67c3..076a8deb0 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 000000000..b2b334aa7 --- /dev/null +++ b/package/webui/50x.html @@ -0,0 +1,30 @@ + + +
+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 000000000..cbf1a3546 --- /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 000000000..1e66a2e7f --- /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-proxy.conf b/package/webui/webui-proxy.conf new file mode 100644 index 000000000..30e546892 --- /dev/null +++ b/package/webui/webui-proxy.conf @@ -0,0 +1,12 @@ +# Shared proxy-pass shape for the webui upstream. Included from every +# location in webui.conf that forwards to the Go app so we don't have to +# restate the header block when nested locations declare their own +# proxy_* directives (which suppresses inheritance from the outer block). + +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.conf b/package/webui/webui.conf new file mode 100644 index 000000000..382caf026 --- /dev/null +++ b/package/webui/webui.conf @@ -0,0 +1,39 @@ +# 256 MiB covers current and near-future firmware images (the aarch64 +# .pkg is ~160 MiB today) without setting an alarming body-size cap on +# devices that may only have 512 MiB of RAM. nginx is the single source +# of truth for the upload ceiling. +# +# Set at server scope (top of file) rather than once on an outer +# `location /` with a nested inner location: nginx inheritance of +# client_max_body_size into a nested location that declares its own +# `proxy_pass` block has bitten us before, silently falling back to +# the http-level default of 1m and rejecting 160 MiB firmware uploads +# with 413. +client_max_body_size 256m; + +location / { + include /etc/nginx/webui-proxy.conf; +} + +location = /firmware/upload { + # Body is buffered by nginx (to /var/cache/nginx/client-body, ext4 + # on eMMC, with 16 KB RAM cap before spill) before forwarding to + # Go. The extra disk pass on a 160 MiB upload is ~30s on eMMC; in + # exchange Go gets a complete, well-formed request with a known + # Content-Length. The previous setup used `proxy_request_buffering + # off` to stream the body straight through and avoid the double + # write, but that raced the response write with the body forward: + # net/http EOFs the body reader at Content-Length, then closes the + # socket, but with streaming the multipart trailer can still be in + # the kernel receive buffer, so close() sends RST instead of FIN + # and nginx serves /50x.html to the client. + proxy_read_timeout 600s; + include /etc/nginx/webui-proxy.conf; +} + +# Liveness probe served by nginx itself — no upstream call, no log line. +# Used by the watchdog div in base.html and the reboot-overlay poller. +location = /device-status { + access_log off; + return 204; +} diff --git a/package/webui/webui.mk b/package/webui/webui.mk new file mode 100644 index 000000000..303a82d6b --- /dev/null +++ b/package/webui/webui.mk @@ -0,0 +1,29 @@ +################################################################################ +# +# 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)/webui-proxy.conf \ + $(TARGET_DIR)/etc/nginx/webui-proxy.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 000000000..731bbbba3 --- /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:8080 \ + -- Web management interface diff --git a/src/confd/src/services.c b/src/confd/src/services.c index 0981f80df..8ebb99fe2 100644 --- a/src/confd/src/services.c +++ b/src/confd/src/services.c @@ -557,7 +557,20 @@ 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). When external + * RESTCONF access is disabled we tighten the location to loopback-only + * by writing the appropriate allow/deny rules into the include file. + */ + FILE *fp = fopen("/etc/nginx/restconf-access.conf", "w"); + if (fp) { + if (!ena) + fputs("allow 127.0.0.1;\nallow ::1;\ndeny all;\n", fp); + fclose(fp); + } + mdns_records(ena ? MDNS_ADD : MDNS_DELETE, restconf); finit_reload("nginx"); return put(cfg); @@ -703,13 +716,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/rauc-installation-status/rauc-installation-status.c b/src/rauc-installation-status/rauc-installation-status.c index d2832dcc5..cecb70e5a 100644 --- a/src/rauc-installation-status/rauc-installation-status.c +++ b/src/rauc-installation-status/rauc-installation-status.c @@ -47,10 +47,10 @@ int main(int argc, char **argv) json_object_set_new(json, "last-error", json_string(strval)); props = rauc_installer_get_progress(rauc); if(props) { - GVariant *val; + gint32 pct; progress = json_object(); - g_variant_get(props, "(@isi)", &val, &strval, NULL); - json_object_set_new(progress, "percentage", json_string(g_variant_print(val, FALSE))); + g_variant_get(props, "(isi)", &pct, &strval, NULL); + json_object_set_new(progress, "percentage", json_integer(pct)); json_object_set_new(progress, "message", json_string(strval)); json_object_set_new(json, "progress", progress); } diff --git a/src/statd/python/yanger/ietf_system.py b/src/statd/python/yanger/ietf_system.py index 60c7c6c01..0ef7a7b7d 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) diff --git a/src/webui/.gitignore b/src/webui/.gitignore new file mode 100644 index 000000000..859dc4235 --- /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 000000000..364389fb6 --- /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 000000000..664896455 --- /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 :8080 --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 000000000..8e3e76651 --- /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 :8080 +``` + +| **Flag** | **Default** | **Description** | +|-------------------|-----------------------------------|-------------------------------------------| +| `--listen` | `:8080` | 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 8080 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 000000000..8f181dbf8 --- /dev/null +++ b/src/webui/go.mod @@ -0,0 +1,17 @@ +module github.com/kernelkit/webui + +go 1.22.0 + +toolchain go1.22.2 + +require ( + github.com/google/go-cmp v0.7.0 // indirect + github.com/openconfig/goyang v1.6.3 // indirect + github.com/pborman/getopt v1.1.0 // indirect +) + +// Local fork of goyang with YANG 1.1 fixes: +// - Uses.Augment: *Augment → []*Augment (multiple augments per uses) +// - Value: add Reference field (when { reference "..."; }) +// - Input/Output: add Must field (must statements in rpc input/output) +replace github.com/openconfig/goyang => ./internal/goyang diff --git a/src/webui/go.sum b/src/webui/go.sum new file mode 100644 index 000000000..e836eaa6d --- /dev/null +++ b/src/webui/go.sum @@ -0,0 +1,6 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/openconfig/goyang v1.6.3 h1:9nWXBwd6b4+nZr8ni7O4zUXVhrVMXCLFz8os5YWFuo4= +github.com/openconfig/goyang v1.6.3/go.mod h1:5WolITjek1NF8yrNERyVZ7jqjOClJTpO8p/+OwmETM4= +github.com/pborman/getopt v1.1.0 h1:eJ3aFZroQqq0bWmraivjQNt6Dmm5M0h2JcDW38/Azb0= +github.com/pborman/getopt v1.1.0/go.mod h1:FxXoW1Re00sQG/+KIkuSqRL/LwQgSkv7uyac+STFsbk= diff --git a/src/webui/internal/auth/login.go b/src/webui/internal/auth/login.go new file mode 100644 index 000000000..38564655a --- /dev/null +++ b/src/webui/internal/auth/login.go @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "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 + // OnLogin is called after every successful login with a context that + // carries the authenticated user's credentials. It is invoked in the + // foreground, so implementations should start their own goroutines for + // slow work. May be nil. + OnLogin func(ctx context.Context) +} + +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 + } + + // Build an authenticated context for post-login work. + ctx := restconf.ContextWithCredentials(r.Context(), restconf.Credentials{ + Username: username, + Password: password, + }) + + // Probe optional features once at login and bake into the session. + caps := handlers.DetectCapabilities(ctx, h.RC) + + // Trigger any post-login hooks (e.g. schema sync) with full credentials. + if h.OnLogin != nil { + h.OnLogin(ctx) + } + + 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 000000000..f8d87aabe --- /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 000000000..25051ce92 --- /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/goyang/.github/dependabot.yml b/src/webui/internal/goyang/.github/dependabot.yml new file mode 100644 index 000000000..a2a66d097 --- /dev/null +++ b/src/webui/internal/goyang/.github/dependabot.yml @@ -0,0 +1,15 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/src/webui/internal/goyang/.github/linters/.golangci.yml b/src/webui/internal/goyang/.github/linters/.golangci.yml new file mode 100644 index 000000000..dca2af2e7 --- /dev/null +++ b/src/webui/internal/goyang/.github/linters/.golangci.yml @@ -0,0 +1,53 @@ +--- +######################### +######################### +## Golang Linter rules ## +######################### +######################### + +# configure golangci-lint +# see https://github.com/golangci/golangci-lint/blob/master/.golangci.example.yml +run: + timeout: 10m +issues: + exclude-rules: + - path: _test\.go + linters: + - dupl + - gosec + - goconst + new: true +linters: + enable: + - gosec + - unconvert + - goconst + - goimports + - gofmt + - gocritic + - govet + - revive + - staticcheck + - unconvert + - unparam + - unused + - wastedassign + - whitespace +linters-settings: + errcheck: + # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; + # default is false: such cases aren't reported by default. + check-blank: true + govet: + # report about shadowed variables + check-shadowing: false + maligned: + # print struct with more effective memory layout or not, false by default + suggest-new: true + gocritic: + disabled-checks: + - singleCaseSwitch + - appendAssign + revive: + ignore-generated-header: true + severity: warning diff --git a/src/webui/internal/goyang/.github/linters/.yaml-lint.yml b/src/webui/internal/goyang/.github/linters/.yaml-lint.yml new file mode 100644 index 000000000..e9ec8bef4 --- /dev/null +++ b/src/webui/internal/goyang/.github/linters/.yaml-lint.yml @@ -0,0 +1,59 @@ +--- +########################################### +# These are the rules used for # +# linting all the yaml files in the stack # +# NOTE: # +# You can disable line with: # +# # yamllint disable-line # +########################################### +rules: + braces: + level: warning + min-spaces-inside: 0 + max-spaces-inside: 0 + min-spaces-inside-empty: 1 + max-spaces-inside-empty: 5 + brackets: + level: warning + min-spaces-inside: 0 + max-spaces-inside: 0 + min-spaces-inside-empty: 1 + max-spaces-inside-empty: 5 + colons: + level: warning + max-spaces-before: 0 + max-spaces-after: 1 + commas: + level: warning + max-spaces-before: 0 + min-spaces-after: 1 + max-spaces-after: 1 + comments: disable + comments-indentation: disable + document-end: disable + document-start: + level: warning + present: true + empty-lines: + level: warning + max: 2 + max-start: 0 + max-end: 0 + hyphens: + level: warning + max-spaces-after: 1 + indentation: + level: warning + spaces: consistent + indent-sequences: true + check-multi-line-strings: false + key-duplicates: enable + line-length: + level: warning + max: 120 + allow-non-breakable-words: true + allow-non-breakable-inline-mappings: true + new-line-at-end-of-file: disable + new-lines: + type: unix + trailing-spaces: disable diff --git a/src/webui/internal/goyang/.github/workflows/go.yml b/src/webui/internal/goyang/.github/workflows/go.yml new file mode 100644 index 000000000..241920888 --- /dev/null +++ b/src/webui/internal/goyang/.github/workflows/go.yml @@ -0,0 +1,15 @@ +name: Go + +on: + push: + branches: [ master ] + pull_request: + schedule: + - cron: "0 0 * * *" + +jobs: + go: + uses: openconfig/common-ci/.github/workflows/go.yml@125b6b58286d116b216e45c33cb859f547965d61 + + linter: + uses: openconfig/common-ci/.github/workflows/linter.yml@125b6b58286d116b216e45c33cb859f547965d61 diff --git a/src/webui/internal/goyang/.gitignore b/src/webui/internal/goyang/.gitignore new file mode 100644 index 000000000..6e92f57d4 --- /dev/null +++ b/src/webui/internal/goyang/.gitignore @@ -0,0 +1 @@ +tags diff --git a/src/webui/internal/goyang/AUTHORS b/src/webui/internal/goyang/AUTHORS new file mode 100644 index 000000000..121ba4efe --- /dev/null +++ b/src/webui/internal/goyang/AUTHORS @@ -0,0 +1,9 @@ +# This is the official list of goyang authors for copyright purposes. +# This file is distinct from the CONTRIBUTORS files. +# See the latter for an explanation. + +# Names should be added to this file as: +# Name or Organization