webui: initial import
Initial import of https://github.com/kernelkit/webui2 @ e344d3f Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
@@ -0,0 +1,2 @@
|
||||
RESTCONF_URL=https://127.0.0.1/restconf
|
||||
INSECURE_TLS=1
|
||||
@@ -0,0 +1 @@
|
||||
../restconf.app
|
||||
@@ -0,0 +1,3 @@
|
||||
allow 127.0.0.1;
|
||||
allow ::1;
|
||||
deny all;
|
||||
@@ -0,0 +1 @@
|
||||
restconf-access-local.conf
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Loading…</title>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="refresh" content="3">
|
||||
<style>
|
||||
html { color-scheme: light dark; font-family: Tahoma, Verdana, Arial, sans-serif; }
|
||||
body { max-width: 32em; margin: 4em auto; text-align: center; }
|
||||
h1 { font-weight: 500; margin-bottom: 0.5em; }
|
||||
p { color: #888; margin: 0.5em 0; }
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
margin-top: 1em;
|
||||
border: 3px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: rot 1s linear infinite;
|
||||
}
|
||||
@keyframes rot { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Loading…</h1>
|
||||
<p>The device is finishing its startup. This page refreshes automatically.</p>
|
||||
<p><span class="spinner" aria-hidden="true"></span></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
webui
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/kernelkit/webui
|
||||
|
||||
go 1.22
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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, `<svg viewBox="0 0 %d %d" xmlns="http://www.w3.org/2000/svg" class="survey-chart">`,
|
||||
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, `<line x1="%d" y1="%d" x2="%.0f" y2="%d" stroke="#e5e7eb" stroke-width="1"/>`,
|
||||
padL, y, float64(padL)+chartW, y)
|
||||
fmt.Fprintf(&b, `<text x="%d" y="%d" text-anchor="end" fill="#9ca3af" font-size="10">%d%%</text>`,
|
||||
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, `<text x="%.0f" y="%.0f" text-anchor="start" `+
|
||||
`fill="#ef4444" font-size="10" opacity="0.8">%d</text>`,
|
||||
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, `<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="#f59e0b" rx="1.5"/>`,
|
||||
bx, baseY-h, barW, h)
|
||||
baseY -= h
|
||||
}
|
||||
if txPct > 0.5 {
|
||||
h := txPct / 100 * float64(chartH)
|
||||
fmt.Fprintf(&b, `<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="#22c55e" rx="1.5"/>`,
|
||||
bx, baseY-h, barW, h)
|
||||
baseY -= h
|
||||
}
|
||||
if rxPct > 0.5 {
|
||||
h := rxPct / 100 * float64(chartH)
|
||||
fmt.Fprintf(&b, `<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="#3b82f6" rx="1.5"/>`,
|
||||
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, `<polygon points="%.1f,%.1f %.1f,%.1f %.1f,%.1f" fill="#2563eb"/>`,
|
||||
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, `<text x="%.1f" y="%d" text-anchor="middle" fill="#6b7280" font-size="10">%s</text>`,
|
||||
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, `<polyline points="%s" fill="none" stroke="#ef4444" `+
|
||||
`stroke-width="1.5" stroke-dasharray="4,3" opacity="0.7"/>`,
|
||||
strings.Join(noisePts, " "))
|
||||
for _, pt := range noisePts {
|
||||
parts := strings.SplitN(pt, ",", 2)
|
||||
fmt.Fprintf(&b, `<circle cx="%s" cy="%s" r="2.5" fill="#ef4444" opacity="0.7"/>`,
|
||||
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, `<rect x="%.0f" y="%d" width="10" height="10" fill="%s" rx="1.5"/>`,
|
||||
lx, ly-9, item.color)
|
||||
fmt.Fprintf(&b, `<text x="%.0f" y="%d" fill="#6b7280" font-size="10">%s</text>`,
|
||||
lx+13, ly, item.label)
|
||||
lx += 13 + float64(len(item.label))*7 + 10
|
||||
}
|
||||
|
||||
if hasNoise {
|
||||
fmt.Fprintf(&b, `<line x1="%.0f" y1="%d" x2="%.0f" y2="%d" `+
|
||||
`stroke="#ef4444" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.7"/>`,
|
||||
lx, ly-4, lx+14, ly-4)
|
||||
lx += 18
|
||||
fmt.Fprintf(&b, `<text x="%.0f" y="%d" fill="#ef4444" font-size="10" opacity="0.8">Noise (dBm)</text>`,
|
||||
lx, ly)
|
||||
lx += 80
|
||||
}
|
||||
|
||||
// In-use legend marker.
|
||||
fmt.Fprintf(&b, `<polygon points="%.0f,%d %.0f,%d %.0f,%d" fill="#2563eb"/>`,
|
||||
lx+5, ly-9, lx+1, ly-1, lx+9, ly-1)
|
||||
fmt.Fprintf(&b, `<text x="%.0f" y="%d" fill="#6b7280" font-size="10">In use</text>`,
|
||||
lx+14, ly)
|
||||
|
||||
b.WriteString(`</svg>`)
|
||||
return template.HTML(b.String())
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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), `"`)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 = `<div class="reboot-overlay" data-timeout="120000" data-interval="2000">
|
||||
<div class="reboot-spinner"></div>
|
||||
<p class="reboot-message">Rebooting…</p>
|
||||
<p class="reboot-status" id="reboot-status">Waiting for device to shut down…</p>
|
||||
</div>`
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 365 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 13h2l2 4 4-10 4 6h4"></path>
|
||||
<path d="M16 3l4 4-4 4"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 242 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
|
||||
<rect x="9" y="9" width="6" height="6"></rect>
|
||||
<path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 15h3M1 9h3M1 15h3"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 356 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 20h16"></path>
|
||||
<path d="M4 16h16"></path>
|
||||
<path d="M4 12h16"></path>
|
||||
<path d="M2 8a5 5 0 0 1 10 0 5 5 0 0 1 10 0"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 13.5h3L7.5 7h4l3 10h4l2.5-6.5h3"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 220 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="6" width="20" height="12" rx="6"></rect>
|
||||
<path d="M7 12h10"></path>
|
||||
<path d="M14 9l3 3-3 3"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 286 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 20V4"></path>
|
||||
<path d="M16 20V4"></path>
|
||||
<path d="M4 12h16"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 251 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="6" cy="12" r="4"></circle>
|
||||
<circle cx="18" cy="12" r="4"></circle>
|
||||
<line x1="10" y1="12" x2="14" y2="12"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 296 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>
|
||||
<polyline points="2 17 12 22 22 17"></polyline>
|
||||
<polyline points="2 12 12 17 22 12"></polyline>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 321 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17.5 19c0-1.7-1.3-3-3-3h-9c-2.8 0-5-2.2-5-5s2.2-5 5-5c.6 0 1.2.1 1.8.3C8.5 3.5 11 2 13.5 2c3.5 0 6.4 2.6 6.9 6h.1c2.5 0 4.5 2 4.5 4.5s-2 2.5-4.5 2.5"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 335 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12.55a11 11 0 0 1 14.08 0"></path>
|
||||
<path d="M1.42 9a16 16 0 0 1 21.16 0"></path>
|
||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path>
|
||||
<line x1="12" y1="20" x2="12.01" y2="20"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 362 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
<circle cx="12" cy="11" r="2"></circle>
|
||||
<path d="M12 13v4"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 13.5h3L7.5 7h4l3 10h4l2.5-6.5h3"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 220 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 2l-2 5-8 8H7v4H3v-4H2v-2h1l8-8 5-2z"></path>
|
||||
<circle cx="16" cy="7" r="2"></circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 266 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="6" cy="6" r="3"></circle>
|
||||
<circle cx="6" cy="18" r="3"></circle>
|
||||
<circle cx="18" cy="12" r="3"></circle>
|
||||
<line x1="9" y1="6" x2="15" y2="10"></line>
|
||||
<line x1="9" y1="18" x2="15" y2="14"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 381 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 6v6l4 2"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 240 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14"></path>
|
||||
<path d="M5 12l4-4"></path>
|
||||
<path d="M5 12l4 4"></path>
|
||||
<path d="M22 6h-6.5c-1.4 0-2.5 1.1-2.5 2.5v7c0 1.4-1.1 2.5-2.5 2.5H4"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 334 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M9 12l2 2 4-4"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 242 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 372 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12.55a11 11 0 0 1 14.08 0"></path>
|
||||
<path d="M1.42 9a16 16 0 0 1 21.16 0"></path>
|
||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path>
|
||||
<line x1="12" y1="20" x2="12.01" y2="20"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 362 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
<circle cx="12" cy="11" r="2"></circle>
|
||||
<path d="M12 13v4"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 300 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,45 @@
|
||||
{{define "iface-counters"}}
|
||||
<div id="iface-counters" hx-get="/interfaces/{{.Name}}/counters" hx-trigger="every 5s" hx-swap="outerHTML">
|
||||
<table class="counters-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Counter</th>
|
||||
<th>RX</th>
|
||||
<th>TX</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Bytes</td>
|
||||
<td class="num">{{.Counters.RxBytes}}</td>
|
||||
<td class="num">{{.Counters.TxBytes}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Unicast</td>
|
||||
<td class="num">{{.Counters.RxUnicast}}</td>
|
||||
<td class="num">{{.Counters.TxUnicast}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Broadcast</td>
|
||||
<td class="num">{{.Counters.RxBroadcast}}</td>
|
||||
<td class="num">{{.Counters.TxBroadcast}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Multicast</td>
|
||||
<td class="num">{{.Counters.RxMulticast}}</td>
|
||||
<td class="num">{{.Counters.TxMulticast}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Discards</td>
|
||||
<td class="num">{{.Counters.RxDiscards}}</td>
|
||||
<td class="num">{{.Counters.TxDiscards}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Errors</td>
|
||||
<td class="num">{{.Counters.RxErrors}}</td>
|
||||
<td class="num">{{.Counters.TxErrors}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,36 @@
|
||||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{.CsrfToken}}">
|
||||
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
|
||||
<title>{{if .PageTitle}}{{.PageTitle}} — {{end}}Infix</title>
|
||||
<link rel="icon" type="image/png" href="/assets/img/jack.png">
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
<script src="/assets/js/htmx.min.js"></script>
|
||||
<script src="/assets/js/app.js"></script>
|
||||
</head>
|
||||
<body hx-boost="true">
|
||||
<div id="conn-banner" class="conn-banner" hidden>Device unreachable</div>
|
||||
<div class="topbar">
|
||||
<div class="logo-area topbar-left"></div>
|
||||
<button id="hamburger-btn" class="hamburger-btn" aria-label="Open menu" aria-expanded="false">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="layout">
|
||||
{{template "sidebar" .}}
|
||||
<main id="content">
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,175 @@
|
||||
{{define "sidebar"}}
|
||||
<nav id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="/assets/img/logo.png" alt="Infix" class="sidebar-logo">
|
||||
</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li class="nav-section-header">Network</li>
|
||||
<li>
|
||||
<a href="/"
|
||||
hx-get="/"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "dashboard"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/dashboard.svg')"></span>
|
||||
Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/interfaces"
|
||||
hx-get="/interfaces"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "interfaces"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/interfaces.svg')"></span>
|
||||
Interfaces
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/routing"
|
||||
hx-get="/routing"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "routing"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/routing.svg')"></span>
|
||||
Routing
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/firewall"
|
||||
hx-get="/firewall"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "firewall"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/firewall.svg')"></span>
|
||||
Firewall
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{if and .Capabilities (.Capabilities.Has "wifi")}}
|
||||
<li class="nav-section-header">Wireless</li>
|
||||
<li>
|
||||
<a href="/wifi"
|
||||
hx-get="/wifi"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "wifi"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/wifi.svg')"></span>
|
||||
WiFi
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
<li class="nav-section-header">VPN</li>
|
||||
<li>
|
||||
<a href="/vpn"
|
||||
hx-get="/vpn"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "vpn"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/wireguard.svg')"></span>
|
||||
WireGuard
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-section-header">Services</li>
|
||||
<li>
|
||||
<a href="/dhcp"
|
||||
hx-get="/dhcp"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "dhcp"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/dhcp.svg')"></span>
|
||||
DHCP
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/ntp"
|
||||
hx-get="/ntp"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "ntp"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/ntp.svg')"></span>
|
||||
NTP
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/lldp"
|
||||
hx-get="/lldp"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "lldp"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/lldp.svg')"></span>
|
||||
LLDP
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-section-header">System</li>
|
||||
{{if and .Capabilities (.Capabilities.Has "containers")}}
|
||||
<li>
|
||||
<a href="/containers"
|
||||
hx-get="/containers"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "containers"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/containers.svg')"></span>
|
||||
Containers
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
<li>
|
||||
<a href="/firmware"
|
||||
hx-get="/firmware"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "firmware"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/firmware.svg')"></span>
|
||||
Firmware
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/keystore"
|
||||
hx-get="/keystore"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "keystore"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/keystore.svg')"></span>
|
||||
Keystore
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<button id="theme-toggle" class="btn btn-sidebar-action theme-toggle" aria-label="Toggle theme">
|
||||
<svg id="icon-auto" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor"></path>
|
||||
</svg>
|
||||
<svg id="icon-light" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<svg id="icon-dark" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
<span>Theme</span>
|
||||
</button>
|
||||
<a href="/config" class="btn btn-sidebar-action" hx-boost="false" download>Download Config</a>
|
||||
<button class="btn btn-sidebar-action btn-danger-hover"
|
||||
hx-post="/reboot"
|
||||
hx-confirm="Reboot the device?"
|
||||
hx-target="#content"
|
||||
hx-swap="innerHTML">Reboot</button>
|
||||
<form method="POST" action="/logout">
|
||||
<input type="hidden" name="csrf" value="{{.CsrfToken}}">
|
||||
<button type="submit" class="btn btn-logout">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
{{end}}
|
||||
@@ -0,0 +1,52 @@
|
||||
{{define "containers.html"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||
|
||||
{{if .Containers}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Containers</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Name</th><th>Image</th><th>Status</th><th>CPU</th><th>Memory</th><th>Uptime</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .Containers}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td class="text-muted">{{.Image}}</td>
|
||||
<td>
|
||||
<span class="iface-status {{if .Running}}iface-up{{else}}iface-down{{end}}"></span>
|
||||
{{.Status}}
|
||||
</td>
|
||||
<td>
|
||||
{{if gt .CPUPct 0}}
|
||||
<div class="health-bar-track" style="width:80px">
|
||||
<div class="health-bar-fill {{if gt .CPUPct 80}}is-crit{{else if gt .CPUPct 60}}is-warn{{end}}" style="width:{{.CPUPct}}%"></div>
|
||||
</div>
|
||||
{{.CPUPct}}%
|
||||
{{else}}<span class="text-muted">—</span>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .MemUsed}}
|
||||
<div class="health-bar-track" style="width:80px">
|
||||
<div class="health-bar-fill {{if gt .MemPct 80}}is-crit{{else if gt .MemPct 60}}is-warn{{end}}" style="width:{{.MemPct}}%"></div>
|
||||
</div>
|
||||
{{.MemUsed}} / {{.MemLimit}}
|
||||
{{else}}<span class="text-muted">—</span>{{end}}
|
||||
</td>
|
||||
<td class="text-muted">{{if .Uptime}}{{.Uptime}}{{else}}—{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{else if not .Error}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Containers</div>
|
||||
<p class="empty-message">No containers configured.</p>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,103 @@
|
||||
{{define "dashboard.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<section class="info-grid">
|
||||
<div class="info-card">
|
||||
<div class="card-header">System Information</div>
|
||||
<div class="card-body" style="padding:0">
|
||||
<table class="info-table">
|
||||
{{if .Hostname}}<tr><th>Hostname</th><td>{{.Hostname}}</td></tr>{{end}}
|
||||
{{if .OSName}}<tr><th>OS</th><td>{{.OSName}} {{.OSVersion}}</td></tr>{{end}}
|
||||
{{if .Machine}}<tr><th>Architecture</th><td>{{.Machine}}</td></tr>{{end}}
|
||||
{{if .Firmware}}<tr><th>Firmware</th><td>{{.Firmware}}</td></tr>{{end}}
|
||||
{{if .Uptime}}<tr><th>Uptime</th><td>{{.Uptime}}</td></tr>{{end}}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="card-header">Resources</div>
|
||||
<div class="card-body" style="padding:0">
|
||||
<table class="info-table">
|
||||
{{if .MemTotal}}
|
||||
<tr>
|
||||
<th>Memory</th>
|
||||
<td>
|
||||
<div class="health-bar-track" style="margin-bottom:0.25rem">
|
||||
<div class="health-bar-fill {{.MemClass}}" style="width:{{.MemPercent}}%"></div>
|
||||
</div>
|
||||
<span class="num">{{.MemUsed}} / {{.MemTotal}} MiB ({{.MemPercent}}%)</span>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{if .Load1}}
|
||||
<tr>
|
||||
<th>Load Average</th>
|
||||
<td>
|
||||
<span class="num">{{.Load1}} · {{.Load5}} · {{.Load15}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Board.Model}}
|
||||
<div class="info-card">
|
||||
<div class="card-header">Hardware</div>
|
||||
<div class="card-body" style="padding:0">
|
||||
<table class="info-table">
|
||||
{{if .Board.Model}}<tr><th>Model</th><td>{{.Board.Model}}</td></tr>{{end}}
|
||||
{{if .Board.Manufacturer}}<tr><th>Manufacturer</th><td>{{.Board.Manufacturer}}</td></tr>{{end}}
|
||||
{{if .Board.SerialNum}}<tr><th>Serial Number</th><td>{{.Board.SerialNum}}</td></tr>{{end}}
|
||||
{{if .Board.HardwareRev}}<tr><th>Hardware Rev</th><td>{{.Board.HardwareRev}}</td></tr>{{end}}
|
||||
{{if .Board.BaseMAC}}<tr><th>Base MAC</th><td>{{.Board.BaseMAC}}</td></tr>{{end}}
|
||||
{{if .Sensors}}
|
||||
{{range .Sensors}}
|
||||
<tr><th>{{.Name}}</th><td>{{.Value}}</td></tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{if .Disks}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Disk Usage</div>
|
||||
<div class="card-body" style="padding:0">
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mount</th>
|
||||
<th>Size</th>
|
||||
<th>Used</th>
|
||||
<th>Available</th>
|
||||
<th>Use%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Disks}}
|
||||
<tr>
|
||||
<td>{{.Mount}}</td>
|
||||
<td>{{.Size}}</td>
|
||||
<td>{{.Used}}</td>
|
||||
<td>{{.Available}}</td>
|
||||
<td>{{.Percent}}%</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,67 @@
|
||||
{{define "dhcp.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if .DHCP}}
|
||||
<section class="info-card">
|
||||
<h2>DHCP Server</h2>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<td>
|
||||
<span class="status-dot {{if .DHCP.Enabled}}status-up{{else}}status-down{{end}}"></span>
|
||||
{{if .DHCP.Enabled}}Enabled{{else}}Disabled{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="stats-row">
|
||||
<span class="zone-badge"><span class="zone-badge-label">Discovers</span><span class="zone-badge-value">{{.DHCP.Stats.InDiscoveries}}</span></span>
|
||||
<span class="zone-badge"><span class="zone-badge-label">Requests</span><span class="zone-badge-value">{{.DHCP.Stats.InRequests}}</span></span>
|
||||
<span class="zone-badge"><span class="zone-badge-label">Releases</span><span class="zone-badge-value">{{.DHCP.Stats.InReleases}}</span></span>
|
||||
<span class="zone-badge"><span class="zone-badge-label">Offers</span><span class="zone-badge-value">{{.DHCP.Stats.OutOffers}}</span></span>
|
||||
<span class="zone-badge"><span class="zone-badge-label">Acks</span><span class="zone-badge-value">{{.DHCP.Stats.OutAcks}}</span></span>
|
||||
<span class="zone-badge"><span class="zone-badge-label">Naks</span><span class="zone-badge-value">{{.DHCP.Stats.OutNaks}}</span></span>
|
||||
</div>
|
||||
|
||||
{{if .DHCP.Leases}}
|
||||
<h3 class="section-subtitle">Active Leases</h3>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th>MAC Address</th>
|
||||
<th>Hostname</th>
|
||||
<th>Expires</th>
|
||||
<th>Client ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .DHCP.Leases}}
|
||||
<tr>
|
||||
<td class="num">{{.Address}}</td>
|
||||
<td class="num">{{.MAC}}</td>
|
||||
<td>{{if .Hostname}}{{.Hostname}}{{else}}<span class="text-muted">—</span>{{end}}</td>
|
||||
<td>{{.Expires}}</td>
|
||||
<td>{{if .ClientID}}{{.ClientID}}{{else}}<span class="text-muted">—</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-muted">No active leases.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{else if not .Error}}
|
||||
<section class="info-card">
|
||||
<p class="text-muted">DHCP server data not available.</p>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,122 @@
|
||||
{{define "firewall.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<section class="info-grid">
|
||||
<div class="info-card">
|
||||
<div class="card-header">Firewall Status</div>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Firewall</th>
|
||||
<td>
|
||||
<span class="iface-status {{if .Enabled}}iface-up{{else}}iface-down{{end}}"></span>
|
||||
{{.EnabledText}}
|
||||
</td>
|
||||
</tr>
|
||||
{{if .DefaultZone}}
|
||||
<tr><th>Default Zone</th><td>{{.DefaultZone}}</td></tr>
|
||||
{{end}}
|
||||
<tr>
|
||||
<th>Lockdown Mode</th>
|
||||
<td>{{if .Lockdown}}Active{{else}}Inactive{{end}}</td>
|
||||
</tr>
|
||||
<tr><th>Log Denied Traffic</th><td>{{.Logging}}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{if .Matrix}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Zone Matrix</div>
|
||||
<div class="matrix-wrap">
|
||||
<table class="zone-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="matrix-corner">→</th>
|
||||
{{range .ZoneNames}}<th><span class="zone-badge">{{.}}</span></th>{{end}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Matrix}}
|
||||
<tr>
|
||||
<th>{{.Zone}}</th>
|
||||
{{range .Cells}}<td class="{{.Class}}">{{.Symbol}}</td>{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="matrix-legend">
|
||||
<span class="legend-item"><span class="legend-swatch matrix-allow"></span> Allow</span>
|
||||
<span class="legend-item"><span class="legend-swatch matrix-deny"></span> Deny</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .Zones}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Zones</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Action</th>
|
||||
<th>Interfaces</th>
|
||||
<th>Networks</th>
|
||||
<th>Services</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Zones}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td><span class="badge badge-{{.Action}}">{{.Action}}</span></td>
|
||||
<td>{{.Interfaces}}</td>
|
||||
<td>{{.Networks}}</td>
|
||||
<td>{{.Services}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .Policies}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Policies</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Priority</th>
|
||||
<th>Action</th>
|
||||
<th>Ingress</th>
|
||||
<th>Egress</th>
|
||||
<th>Services</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Policies}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td class="num">{{.Priority}}</td>
|
||||
<td><span class="badge badge-{{.Action}}">{{.Action}}</span></td>
|
||||
<td>{{.Ingress}}</td>
|
||||
<td>{{.Egress}}</td>
|
||||
<td>{{.Services}}{{if .Masquerade}} <span class="badge badge-continue">masq</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,69 @@
|
||||
{{define "firmware.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Message}}
|
||||
<div class="alert alert-info">{{.Message}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Installer}}{{if .Installer.Active}}
|
||||
<section class="info-card" hx-get="/firmware" hx-trigger="every 3s" hx-target="#content">
|
||||
<div class="card-header">Install in Progress</div>
|
||||
<div class="progress-bar-wrap">
|
||||
<div class="progress-bar" data-progress="{{.Installer.Percentage}}"></div>
|
||||
</div>
|
||||
<p class="progress-text">{{.Installer.Percentage}}% — {{.Installer.Message}}</p>
|
||||
</section>
|
||||
{{end}}{{end}}
|
||||
|
||||
{{if .Installer}}{{if .Installer.LastError}}
|
||||
<div class="alert alert-error">Last error: {{.Installer.LastError}}</div>
|
||||
{{end}}{{end}}
|
||||
|
||||
<section class="info-card">
|
||||
<div class="card-header">Firmware Slots</div>
|
||||
{{if .Slots}}
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Slot</th>
|
||||
<th>State</th>
|
||||
<th>Version</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Slots}}
|
||||
<tr>
|
||||
<td>{{.Name}}{{if .BootName}} ({{.BootName}}){{end}}{{if .Booted}} <span class="badge badge-accept">booted</span>{{end}}</td>
|
||||
<td>{{.State}}</td>
|
||||
<td>{{if .Version}}{{.Version}}{{else}}-{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty-message">No firmware slot information available.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="info-card">
|
||||
<div class="card-header">Install Firmware</div>
|
||||
<form hx-post="/firmware/install" hx-swap="none" class="firmware-form">
|
||||
<input type="hidden" name="csrf" value="{{.CsrfToken}}">
|
||||
<div class="form-group">
|
||||
<label for="fw-url">Bundle URL</label>
|
||||
<input type="url" id="fw-url" name="url" placeholder="https://example.com/firmware.raucb" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary"
|
||||
hx-confirm="Install firmware from this URL?"
|
||||
{{if .Installer}}{{if .Installer.Active}}disabled{{end}}{{end}}>Install</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,155 @@
|
||||
{{define "iface-detail.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="iface-detail-header">
|
||||
<a href="/interfaces" hx-get="/interfaces" hx-target="#content" hx-push-url="true" class="back-link">← Interfaces</a>
|
||||
<h2>{{.Name}}</h2>
|
||||
</div>
|
||||
|
||||
<div class="info-grid">
|
||||
<section class="info-card">
|
||||
<div class="card-header">Interface Info</div>
|
||||
<table class="info-table">
|
||||
<tr><th>Name</th><td>{{.Name}}</td></tr>
|
||||
<tr><th>Type</th><td>{{.Type}}</td></tr>
|
||||
<tr><th>Index</th><td>{{.IfIndex}}</td></tr>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<td><span class="status-dot {{if .StatusUp}}status-up{{else}}status-down{{end}}"></span>{{.Status}}</td>
|
||||
</tr>
|
||||
{{if .PhysAddr}}<tr><th>MAC Address</th><td>{{.PhysAddr}}</td></tr>{{end}}
|
||||
{{if .MTU}}<tr><th>MTU</th><td>{{.MTU}}</td></tr>{{end}}
|
||||
{{if .Speed}}<tr><th>Speed</th><td>{{.Speed}}</td></tr>{{end}}
|
||||
{{if .Duplex}}<tr><th>Duplex</th><td>{{.Duplex}}</td></tr>{{end}}
|
||||
{{if .AutoNeg}}<tr><th>Auto-negotiation</th><td>{{.AutoNeg}}</td></tr>{{end}}
|
||||
{{if .WiFiMode}}<tr><th>Mode</th><td>{{.WiFiMode}}</td></tr>{{end}}
|
||||
{{if .WiFiSSID}}<tr><th>SSID</th><td>{{.WiFiSSID}}</td></tr>{{end}}
|
||||
{{if .WiFiSignal}}<tr><th>Signal</th><td>{{.WiFiSignal}}</td></tr>{{end}}
|
||||
{{if .WiFiRxSpeed}}<tr><th>RX Speed</th><td>{{.WiFiRxSpeed}}</td></tr>{{end}}
|
||||
{{if .WiFiTxSpeed}}<tr><th>TX Speed</th><td>{{.WiFiTxSpeed}}</td></tr>{{end}}
|
||||
{{if .WiFiStationCount}}<tr><th>Connected Stations</th><td>{{.WiFiStationCount}}</td></tr>{{end}}
|
||||
{{if .WGPeerSummary}}<tr><th>Peers</th><td>{{.WGPeerSummary}}</td></tr>{{end}}
|
||||
</table>
|
||||
{{if .Addresses}}
|
||||
<h3>Addresses</h3>
|
||||
<table class="info-table">
|
||||
{{range .Addresses}}
|
||||
<tr>
|
||||
<td>{{.Address}}{{if .Origin}} <span class="addr-origin">({{.Origin}})</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="info-card">
|
||||
<div class="card-header">Counters</div>
|
||||
{{template "iface-counters" .}}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{{if .WiFiStations}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Connected Stations</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>MAC</th>
|
||||
<th>Signal</th>
|
||||
<th>Time</th>
|
||||
<th>RX Pkts</th>
|
||||
<th>TX Pkts</th>
|
||||
<th>RX Bytes</th>
|
||||
<th>TX Bytes</th>
|
||||
<th>RX Speed</th>
|
||||
<th>TX Speed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .WiFiStations}}
|
||||
<tr>
|
||||
<td>{{.MAC}}</td>
|
||||
<td>{{if .Signal}}<span class="signal-{{.SignalCSS}}">{{.Signal}}</span>{{end}}</td>
|
||||
<td>{{.Time}}</td>
|
||||
<td class="num">{{.RxPkts}}</td>
|
||||
<td class="num">{{.TxPkts}}</td>
|
||||
<td class="num">{{.RxBytes}}</td>
|
||||
<td class="num">{{.TxBytes}}</td>
|
||||
<td class="num">{{.RxSpeed}}</td>
|
||||
<td class="num">{{.TxSpeed}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .ScanResults}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Scan Results</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SSID</th>
|
||||
<th>BSSID</th>
|
||||
<th>Signal</th>
|
||||
<th>Channel</th>
|
||||
<th>Security</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .ScanResults}}
|
||||
<tr>
|
||||
<td>{{if .SSID}}{{.SSID}}{{else}}<em>Hidden</em>{{end}}</td>
|
||||
<td>{{.BSSID}}</td>
|
||||
<td>{{if .Signal}}<span class="signal-{{.SignalCSS}}">{{.Signal}}</span>{{end}}</td>
|
||||
<td>{{.Channel}}</td>
|
||||
<td>{{.Encryption}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .WGPeers}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">WireGuard Peers</div>
|
||||
{{range .WGPeers}}
|
||||
<div class="wg-peer">
|
||||
<table class="info-table">
|
||||
<tr><th>Public Key</th><td class="key-full">{{.PublicKey}}</td></tr>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<td><span class="iface-status {{if .StatusUp}}iface-up{{else}}iface-down{{end}}"></span>{{.Status}}</td>
|
||||
</tr>
|
||||
{{if .Endpoint}}<tr><th>Endpoint</th><td>{{.Endpoint}}</td></tr>{{end}}
|
||||
{{if .Handshake}}<tr><th>Latest Handshake</th><td>{{.Handshake}}</td></tr>{{end}}
|
||||
{{if .TxBytes}}<tr><th>Transfer TX</th><td class="num">{{.TxBytes}}</td></tr>{{end}}
|
||||
{{if .RxBytes}}<tr><th>Transfer RX</th><td class="num">{{.RxBytes}}</td></tr>{{end}}
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .EthFrameStats}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Ethernet Frame Statistics</div>
|
||||
<dl class="eth-stats">
|
||||
{{range .EthFrameStats}}
|
||||
<div class="eth-stats-row">
|
||||
<dt>{{.Key}}</dt>
|
||||
<dd class="num">{{.Value}}</dd>
|
||||
</div>
|
||||
{{end}}
|
||||
</dl>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "interfaces.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Interfaces}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Interfaces</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>MAC</th>
|
||||
<th>Address</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Interfaces}}
|
||||
<tr>
|
||||
<td class="iface-name">{{if .Indent}}<span class="tree-pipe">{{.Indent}}</span>{{end}}<a href="/interfaces/{{.Name}}" hx-get="/interfaces/{{.Name}}" hx-target="#content" hx-push-url="true">{{.Name}}</a></td>
|
||||
<td>{{.Type}}</td>
|
||||
<td><span class="iface-status {{if .StatusUp}}iface-up{{else}}iface-down{{end}}"></span>{{.Status}}</td>
|
||||
<td>{{.PhysAddr}}</td>
|
||||
<td>{{range $i, $a := .Addresses}}{{if $i}}<br>{{end}}{{$a.Address}}{{if $a.Origin}} <span class="addr-origin">({{$a.Origin}})</span>{{end}}{{end}}</td>
|
||||
<td>{{.Detail}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{else if not .Error}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Interfaces</div>
|
||||
<p class="empty-message">No interfaces found.</p>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,74 @@
|
||||
{{define "keystore.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Empty}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Keystore</div>
|
||||
<p class="empty-message">Keystore is empty.</p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .SymmetricKeys}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Symmetric Keys</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Format</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .SymmetricKeys}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.Format}}</td>
|
||||
<td><code class="key-full">{{.Value}}</code></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .AsymmetricKeys}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Asymmetric Keys</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Algorithm</th>
|
||||
<th>Public Key</th>
|
||||
{{if (index .AsymmetricKeys 0).PrivateKeyFull}}<th>Private Key</th>{{end}}
|
||||
<th>Certificates</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AsymmetricKeys}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.Algorithm}}</td>
|
||||
<td>{{if .PublicKeyFull}}<code class="key-full">{{.PublicKeyFull}}</code>{{else}}-{{end}}</td>
|
||||
{{if .PrivateKeyFull}}
|
||||
<td><code class="key-full">{{.PrivateKeyFull}}</code></td>
|
||||
{{end}}
|
||||
<td>{{range $i, $c := .Certificates}}{{if $i}}, {{end}}{{$c}}{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||