web

package module
v0.4.6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 23, 2026 License: GPL-3.0 Imports: 23 Imported by: 1

README

config-manager-web

Browser-based dashboard for Config Manager. Designed for headless Debian-based nodes (Raspbian Bookworm ARM, Debian Bullseye slim).

Features

  • Dashboard with hostname, OS, architecture, and live uptime
  • Update manager — pending counts, package list, log viewer, run full or security-only updates with confirmation dialogs
  • Job progress polling — after triggering long-running jobs, shows real-time status with auto-polling until completion or failure
  • Network info — interfaces, connectivity status, DNS configuration
  • Network management — set static IP, set DNS servers, delete static IP, rollback interface, rollback DNS with confirmation dialogs
  • Cookie-based authentication using the same Bearer token as the API
  • Responsive dark theme — works on phones, tablets, and desktops
  • Server-rendered with htmx — no JavaScript build step required
  • Write-policy awareness — network write operations denied by interface policy show a warning-level toast with actionable guidance instead of a generic error
  • Skeleton loading — pages render instant skeleton placeholders, then htmx lazy-loads data from fragment endpoints for perceived performance
  • Dynamic plugin sidebar — auto-discovers plugins from the core API registry
  • Sidebar system info — hostname, uptime, and API connection indicator
  • Job history — paginated run history table with status icons, Previous/Next navigation

Architecture

Plugin list caching

The sidebar plugin list is cached with a 30-second TTL to avoid repeated API calls on every page load. When the TTL expires, the next request refreshes the cache from the core API. A refresh mutex prevents thundering-herd: if multiple requests arrive while the cache is expired, only one goroutine fetches from the API while the others wait and then read the refreshed cache.

Node info caching

The /api/v1/node response is cached with a 5-second TTL to deduplicate calls when the sidebar and a fragment endpoint both need node data within the same page load cycle. This prevents redundant API traffic on dashboard loads.

Sidebar resilience

The sidebar degrades gracefully when the core API is unavailable:

  1. Fresh cache — served directly, no API call.
  2. Expired (stale) cache — if the API fetch fails, the last-known plugin list is returned regardless of TTL expiry, keeping the sidebar populated.
  3. No cached data at all — the template displays a "plugins unavailable" message instead of an empty sidebar.
Concurrency limits

Generic plugin pages fetch all GET endpoints concurrently. A semaphore (channel of size 10) caps the number of in-flight API calls per request, preventing a plugin with many endpoints from overwhelming the core API.

Response body limit

Every API response is capped at 2 MB before JSON decoding (maxResponseBytes in apiclient.go). This prevents unbounded memory allocation on ARM devices with limited RAM. Oversized responses return a descriptive error; the constant is a single-line change if it needs tuning.

Request body limit

All form-handling POST handlers — including the unauthenticated /auth/login endpoint — use MaxBytesReader to cap incoming form data at 1 MB (maxFormBytes in routes_network.go). This prevents oversized submissions from consuming memory on resource-constrained ARM devices. Requests exceeding the limit are redirected back to the login page or receive an inline error with a toast notification.

Network write error handling

writeNetworkError in routes_network.go renders inline alerts with expandable details and an out-of-band toast notification. It distinguishes 403 Forbidden responses from other errors:

  • 403 — the toast level is downgraded from error to warning, and the title is overridden to "Interface protected by policy" so the user sees actionable guidance instead of a scary red error.
  • All other errors — rendered as error-level alerts with the original operation title (e.g. "Failed to set static IP for eth0").

The toastLevel variable is validated against a whitelist ("error" / "warning") before being interpolated into the HTML class attribute. This prevents CSS-class injection if future code paths introduce new levels without sanitization. The toastOOB helper applies a second whitelist for defense in depth.

RoutePrefix validation

Plugin registry entries include a RoutePrefix used to build API paths. Before caching, each prefix is validated: it must start with /, must not contain path-traversal sequences (.., including percent-encoded variants), and must not contain control characters. Entries that fail validation are dropped with a warning log.

Documentation

Development

# lint
golangci-lint run

# test
go test ./...

CI runs automatically on push/PR to main via GitHub Actions (.github/workflows/ci.yml).

Contributing

See CONTRIBUTING.md for guidelines.

Security

The web dashboard applies multiple layers of defense when handling untrusted data from the Config Manager API and plugin registry:

  • Input sanitization — All API responses are sanitized before rendering in HTML templates, preventing XSS and injection attacks.
  • Path validation — Plugin paths are validated against directory-traversal attacks (including percent-encoded variants such as %2e%2e) before building API requests. Route prefixes must start with /, must not contain .. sequences, and must not contain control characters.
  • Request body size limits — All form-handling POST handlers (including the login endpoint) use MaxBytesReader to cap incoming form data at 1 MB (maxFormBytes), preventing oversized submissions from consuming memory on resource-constrained ARM devices.
  • Response body size limits — API responses capped at 2 MB via LimitReader to prevent unbounded memory allocation on devices with limited RAM.
  • Security response headers — Every response includes X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Content-Security-Policy, and Referrer-Policy: same-origin.
  • Static asset caching — Static files are served with Cache-Control headers to reduce redundant requests and improve load times.

For vulnerability reporting see SECURITY.md.

License

See LICENSE for details.

Documentation

Overview

Package web provides a browser-based dashboard for Config Manager. It uses htmx + Go html/template for server-rendered pages with dynamic updates, served alongside the JSON API on the same port.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func NewHandler

func NewHandler(apiURL, authToken string) http.Handler

NewHandler creates a web UI handler that renders pages and proxies actions to the CM JSON API at apiURL. When authToken is non-empty, a login page gates access via cookie-based sessions.

Types

type APIError

type APIError struct {
	StatusCode int
	Message    string
}

APIError is returned when the core API responds with a non-2xx status code. Callers can type-assert to distinguish retryable server errors (5xx) from non-retryable client errors (4xx).

func (*APIError) Error

func (e *APIError) Error() string

func (*APIError) Retryable

func (e *APIError) Retryable() bool

Retryable returns true for 5xx and 429 (rate-limit) status codes. 4xx errors (except 429) indicate a permanent problem and should not be retried.

type ActionInfo

type ActionInfo struct {
	Description string
	Path        string // relative path, e.g. "/run"
}

ActionInfo describes a POST endpoint for the generic plugin template.

type DNSConfig

type DNSConfig struct {
	Nameservers []string `json:"nameservers"`
	Search      []string `json:"search,omitempty"`
}

DNSConfig holds the response from GET /api/v1/plugins/network/dns.

type EndpointData

type EndpointData struct {
	Description string
	Data        string
	Error       string
}

EndpointData holds the result of fetching a single GET endpoint.

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler serves the web UI and proxies actions to the CM JSON API.

func (*Handler) ServeHTTP

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler.

type JobRun

type JobRun struct {
	JobID     string `json:"job_id"`
	Status    string `json:"status"` // Core API: "running", "completed", "failed"; web-layer synthetic: "error"
	StartedAt string `json:"started_at,omitempty"`
	EndedAt   string `json:"ended_at,omitempty"`
	Duration  string `json:"duration,omitempty"`
	Error     string `json:"error,omitempty"`
}

JobRun holds a job execution record returned by GET /api/v1/jobs/{id}/runs/latest and GET /api/v1/jobs/{id}/runs (paginated list).

type NetworkInterface

type NetworkInterface struct {
	Name  string `json:"name"`
	MAC   string `json:"mac"`
	IP    string `json:"ip,omitempty"`
	State string `json:"state"`
}

NetworkInterface holds one entry from GET /api/v1/plugins/network/interfaces.

type NetworkStatus

type NetworkStatus struct {
	DefaultGateway    string `json:"default_gateway,omitempty"`
	DNSReachable      bool   `json:"dns_reachable"`
	InternetReachable bool   `json:"internet_reachable"`
}

NetworkStatus holds the response from GET /api/v1/plugins/network/status.

type NetworkWriteResult

type NetworkWriteResult struct {
	Valid    bool           `json:"valid,omitempty"`
	Changes  []string       `json:"changes,omitempty"`
	Current  map[string]any `json:"current,omitempty"`
	Proposed map[string]any `json:"proposed,omitempty"`
	Message  string         `json:"message,omitempty"`
}

NetworkWriteResult is the response from network write operations.

type NodeInfo

type NodeInfo struct {
	Hostname      string `json:"hostname"`
	OS            string `json:"os"`
	Kernel        string `json:"kernel"`
	Arch          string `json:"arch"`
	UptimeSeconds int    `json:"uptime_seconds"`
}

NodeInfo holds the response from GET /api/v1/node.

type PendingUpdate

type PendingUpdate struct {
	Package        string `json:"package"`
	CurrentVersion string `json:"current_version"`
	NewVersion     string `json:"new_version"`
	Security       bool   `json:"security"`
}

PendingUpdate holds one entry from GET /api/v1/plugins/update/status.

type PluginEndpoint

type PluginEndpoint struct {
	Method      string `json:"method"`
	Path        string `json:"path"`
	Description string `json:"description"`
}

PluginEndpoint describes a single endpoint exposed by a plugin.

type PluginInfo

type PluginInfo struct {
	Name        string           `json:"name"`
	Version     string           `json:"version"`
	Description string           `json:"description"`
	RoutePrefix string           `json:"route_prefix"`
	Endpoints   []PluginEndpoint `json:"endpoints"`
}

PluginInfo holds metadata returned by GET /api/v1/plugins.

type PluginSettings

type PluginSettings struct {
	Config map[string]any `json:"config"`
}

PluginSettings holds the response from GET /api/v1/plugins/{name}/settings.

type PluginSettingsUpdateResult

type PluginSettingsUpdateResult struct {
	Config  map[string]any `json:"config"`
	Warning string         `json:"warning,omitempty"`
}

PluginSettingsUpdateResult holds the response from PUT /api/v1/plugins/{name}/settings.

type RunStatus

type RunStatus struct {
	Type      string `json:"type"`
	Status    string `json:"status"`
	StartedAt string `json:"started_at,omitempty"`
	Duration  string `json:"duration,omitempty"`
	Packages  int    `json:"packages"`
	Log       string `json:"log,omitempty"`
}

RunStatus holds the response from GET /api/v1/plugins/update/logs.

type Toast

type Toast struct {
	Level   string // "success", "error", "warning"
	Message string
}

Toast represents a brief notification shown at the top of the viewport.

type UpdateConfig

type UpdateConfig struct {
	SecurityAvailable *bool  `json:"security_available"`
	AutoSecurity      *bool  `json:"auto_security"`
	SecuritySource    string `json:"security_source,omitempty"`
	Schedule          string `json:"schedule,omitempty"`
}

UpdateConfig holds the response from GET /api/v1/plugins/update/config.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL