webui: initial import

Initial import of https://github.com/kernelkit/webui2 @ e344d3f

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-14 22:07:22 +02:00
parent a9c722fcd5
commit bc535099ec
106 changed files with 8830 additions and 7 deletions
+2
View File
@@ -0,0 +1,2 @@
RESTCONF_URL=https://127.0.0.1/restconf
INSECURE_TLS=1
+1
View File
@@ -0,0 +1 @@
../restconf.app
@@ -0,0 +1,3 @@
allow 127.0.0.1;
allow ::1;
deny all;
+1
View File
@@ -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;
+2 -1
View File
@@ -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
+1
View File
@@ -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
+2 -1
View File
@@ -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
+1
View File
@@ -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
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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"
+2
View File
@@ -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))
+30
View File
@@ -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>
+9
View File
@@ -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.
+24
View File
@@ -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;
}
+9
View File
@@ -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;
}
+27
View File
@@ -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))
+3
View File
@@ -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
+31 -3
View File
@@ -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");
+1
View File
@@ -0,0 +1 @@
webui
+19
View File
@@ -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.
+15
View File
@@ -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
+120
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
module github.com/kernelkit/webui
go 1.22
+131
View File
@@ -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)
}
}
+3
View File
@@ -0,0 +1,3 @@
// SPDX-License-Identifier: MIT
package auth
+156
View File
@@ -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"))
}
}
+21
View File
@@ -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)
}
+186
View File
@@ -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")
}
}
+469
View File
@@ -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())
}
}
+170
View File
@@ -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()))
}
}
+281
View File
@@ -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"}
}
+988
View File
@@ -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())
}
+190
View File
@@ -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] + "..."
}
+138
View File
@@ -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
}
+147
View File
@@ -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), `"`)
}
+301
View File
@@ -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")
}
}
+213
View File
@@ -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&hellip;</p>
<p class="reboot-status" id="reboot-status">Waiting for device to shut down&hellip;</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)
}
+230
View File
@@ -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)
}
}
+69
View File
@@ -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")
}
}
+425
View File
@@ -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
}
+69
View File
@@ -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")
}
}
+223
View File
@@ -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
}
+63
View File
@@ -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
}
+16
View File
@@ -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)
+102
View File
@@ -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
}
+176
View File
@@ -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)
}
+161
View File
@@ -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
}
+95
View File
@@ -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
}
+85
View File
@@ -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
}
}
File diff suppressed because it is too large Load Diff
@@ -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

+6
View File
@@ -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

+4
View File
@@ -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

+3
View File
@@ -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

+5
View File
@@ -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

+5
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

+7
View File
@@ -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

+4
View File
@@ -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

+6
View File
@@ -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

+4
View File
@@ -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

+6
View File
@@ -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

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+212
View File
@@ -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');
}
});
});
})();
File diff suppressed because one or more lines are too long
@@ -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}}
+36
View File
@@ -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}}
+175
View File
@@ -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}}
+52
View File
@@ -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}}
+103
View File
@@ -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}} &middot; {{.Load5}} &middot; {{.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}}
+67
View File
@@ -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}}
+122
View File
@@ -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">&rarr;</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}}
+69
View File
@@ -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}}
+155
View File
@@ -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">&larr; 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}}
+46
View File
@@ -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}}
+74
View File
@@ -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}}

Some files were not shown because too many files have changed in this diff Show More