diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 78958be..02bbd20 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,3 +25,25 @@ jobs: - name: Test run: make test + + lint: + runs-on: ubuntu-latest + permissions: + contents: read + # only-new-issues reads the pull request diff + pull-requests: read + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: 1.25 + + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: v2.14 + # The code predates most of the linters "default: all" now enables; + # fail on issues introduced by a change instead of the whole backlog. + only-new-issues: true diff --git a/.golangci.yml b/.golangci.yml index 4baade2..c7d09a7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,43 +1,72 @@ -linters-settings: - gci: - sections: - - standard - - default - - prefix(github.com/rtbrick) - godot: - # list of regexps for excluding particular comment lines from check - exclude: - - '@.*' - -issues: - exclude-rules: - - linters: - - funlen - - dupl - - bodyclose - - gocritic - - unparam - - lll - path: _test.go - +version: "2" linters: - enable-all: true + default: all + # "all" keeps growing with every golangci-lint release; the linters below + # are pure style preferences this codebase deliberately does not follow + # (or are deprecated, or need per-project config that adds nothing here, + # like depguard). disable: - - scopelint - - golint - - interfacer - - maligned - - prealloc + - depguard + - err113 + - errchkjson + - errorlint + - exhaustruct + - exhaustruct_v5 + - funcorder - gochecknoglobals - - wrapcheck - - testpackage + - godoclint + - gomodguard + - lll + - nestif - nlreturn - - exhaustivestruct - - wsl + - noinlineerr - paralleltest - - varnamelen - - goerr113 + - prealloc - tagliatelle - - errorlint - - errchkjson - - nestif \ No newline at end of file + - testpackage + - varnamelen + - wrapcheck + - wsl + - wsl_v5 + settings: + godot: + exclude: + - '@.*' + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - bodyclose + - dupl + - funlen + - gocritic + - lll + - unparam + path: _test.go + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + settings: + gci: + sections: + - standard + - default + - prefix(github.com/rtbrick) + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index f868135..75105bb 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -28,14 +28,29 @@ nfpms: - deb bindir: /usr/local/bin section: utils + # bngblaster ships as a GitHub release .deb rather than from an apt + # repository, so a hard dependency would make a plain dpkg -i fail. + recommends: + - bngblaster contents: - src: debian/scripts/systemd/rtbrick-bngblasterctrl.service dst: /lib/systemd/system/rtbrick-bngblasterctrl.service file_info: mode: 0644 + - src: debian/scripts/default/rtbrick-bngblasterctrl + dst: /etc/default/rtbrick-bngblasterctrl + type: config|noreplace + file_info: + mode: 0644 + - src: debian/scripts/logrotate/rtbrick-bngblasterctrl + dst: /etc/logrotate.d/rtbrick-bngblasterctrl + type: config|noreplace + file_info: + mode: 0644 scripts: postinstall: debian/scripts/postinstall.sh preremove: debian/scripts/preremove.sh + postremove: debian/scripts/postremove.sh checksum: name_template: 'checksums.txt' snapshot: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..103e718 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,83 @@ +# CLAUDE.md + +REST controller daemon (`bngblasterctrl`) for the [BNG Blaster](https://github.com/rtbrick/bngblaster). +It creates, starts, stops and inspects multiple `bngblaster` test instances on one host and +wraps each instance's JSON-RPC control socket as a REST API. +It also ships an embedded web UI (served on `/`, on by default, disable with `-ui=false`) that runs +on top of that REST API to manage instances from a browser. + +## Commands + +```sh +make build # -> bin/_/bngblasterctrl (version from latest git tag) +make test # go test -v -cover ./... (what CI runs, with make build) +go test ./pkg/server -run TestServer_create # single test +make lint # golangci-lint v2 (default: all, see .golangci.yml); CI only fails on new issues +make fumpt # gofumpt formatting +make gci # import order: standard, default, github.com/rtbrick +go generate ./pkg/controller # regenerate repositorymock.go (needs matryer/moq) +``` + +Run locally without root by pointing at a writable folder: + +```sh +./bin/linux_amd64/bngblasterctrl -d /tmp/bngblaster -debug +``` + +## Layout + +- `cmd/bngblasterctrl/` – flag parsing, zerolog setup (warn+ goes to stderr, rest to stdout), HTTP server. +- `pkg/controller/` – instance lifecycle on disk and process management. + - `Repository` interface (`model.go`) is the seam between HTTP and the file system/processes; + `DefaultRepository` (`repository.go`) is the real implementation. + - Each instance is a folder `//` holding `config.json`, `run.json`, `run.pid`, + `run.sock`, `run.log`, `run_report.json`, `run.pcap`, `run.stdout`, `run.stderr` + (filename constants in `repository.go`). "Running" is derived from these files, not from in-memory state. + - `prom.go` – Prometheus metrics collected from running instances via the control socket. +- `pkg/server/` – gorilla/mux router (`server.go` `routes()`), one file per feature + (streams, sessions, overview, logs, files, ui, apidocs). `cache.go` is a short-TTL, + per-instance summary cache with in-flight dedup; invalidate it on any lifecycle change. + `hardening.go` holds the auth-independent protections (cross-origin check for + state-changing requests, `-allowed-hosts`, security headers/CSP, body size limits); bound every + new request body with `http.MaxBytesReader` and log lifecycle changes via `auditLog`. +- `pkg/server/webui/` – experimental embedded SPA. Vanilla HTML/CSS/JS, **no build step, + no framework, no npm** – files are `go:embed`ed and served as-is. `index.html` is a Go + template (`{{.AssetVersion}}` cache-busting). +- `docs/` – `swagger.yaml` + Swagger UI, embedded into the binary and also published via GitHub Pages. +- `debian/` – systemd unit, `/etc/default` env file, logrotate, install scripts (packaged by goreleaser). + +## Conventions + +- Every Go file starts with: + ```go + // SPDX-License-Identifier: BSD-3-Clause + // Copyright (C) 2020-2026, RtBrick, Inc. + ``` +- Constructors use functional options (`NewServer(repo, WithUI(...))`, `NewDefaultRepository(WithConfigFolder(...))`). + Optional surface sits behind a flag + option. `-ui`, `-upload` and `-interfaces-api` default to on in the + binary (disable with `-flag=false`); the server/repository options themselves still default to off. +- Handlers are methods returning `http.HandlerFunc`. Always sanitize the instance path variable with + `cleanPathVariable`, and file names with `filepath.Base` + `isUnsafeFileName`. There is no auth yet + (`authMiddleware` is a no-op hook), so path-traversal safety matters. +- Use `JSONError` / `JSONNotFound` for error responses; map `controller.ErrBlaster*` errors to HTTP status + (running → 412, not exists → 404). +- Logging via `github.com/rs/zerolog/log` with structured fields. +- Comments explain *why*; the codebase uses fairly thorough doc comments – match that density. + +## Testing + +- Server tests use `controller.RepositoryMock` (moq) plus `httpexpect`/`httptest`; table-driven with `testify/require`. +- Process tests fake `bngblaster` via `controller.ExecCommand` and the `TestHelperProcess` / + `GO_WANT_HELPER_PROCESS` pattern (`process_test.go`). +- Fixtures live in `pkg/controller/td/`. +- After changing the `Repository` interface, regenerate the mock or the build breaks. + +## When changing the API + +Update in the same change: the route in `server.go`, `docs/swagger.yaml`, the web UI (`app.js`) if it +consumes the endpoint, and the README if flags or defaults change. + +## Release + +Tag-driven via goreleaser (`.goreleaser.yaml`, `.github/workflows/release.yml`): linux/amd64 static +binary (`CGO_ENABLED=0`) + `.deb`. `main.Version` is injected through ldflags. diff --git a/LICENSE b/LICENSE index f26d2b4..c7c4c01 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (C) 2020-2025, RtBrick, Inc. +Copyright (C) 2020-2026, RtBrick, Inc. All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.md b/README.md index 79c9e11..9e4a310 100644 --- a/README.md +++ b/README.md @@ -12,22 +12,49 @@ as REST API and provides endpoints to download logs and reports. ![BNG Blaster Controller](docs/controller.png "BNG Blaster Controller") +## Installation + +Pre-built debian packages, as well as plain `tar.gz` archives, are +published on the [GitHub releases page](https://github.com/rtbrick/bngblaster-controller/releases). + +Installing the Debian package registers and starts a systemd service: + +``` +$ sudo dpkg -i bngblaster-controller__amd64.deb +``` + +This installs the `bngblasterctrl` binary to `/usr/local/bin/bngblasterctrl`, +a systemd unit (`rtbrick-bngblasterctrl.service`), a default environment +file at `/etc/default/rtbrick-bngblasterctrl` (see +[Configuration](#configuration) below), and a logrotate policy at +`/etc/logrotate.d/rtbrick-bngblasterctrl` for the service's stdout/stderr log +files under `/var/log/`. The service is enabled and started automatically. + +Alternatively, build from source: + +``` +$ make build +$ sudo ./bin/_/bngblasterctrl +``` + +The blaster instance needs at least the permissions required to run +the `bngblaster` itself. + ## Usage The controller comes with good defaults, just starting the controller will give you an instance that: * runs on port `8001` -* assumes bngblaster is installed at `/usr/sbin/bngblaster` +* assumes bngblaster is installed at `/usr/bin/bngblaster` * uses `/var/bngblaster` as storage directory -The blaster instance needs at least the permissions required to run -the `bngblaster` itself. - ``` -$ ./bngblasterctrl -h -Usage of bngblasterctrl: +$ /usr/local/bin/bngblasterctrl -h +Usage of /usr/local/bin/bngblasterctrl: -addr string HTTP network address (default ":8001") + -allowed-hosts string + comma-separated host names clients may use to reach the controller, against DNS rebinding (IP addresses and localhost are always allowed; empty allows any host) -color turn on color of color output -console @@ -37,11 +64,120 @@ Usage of bngblasterctrl: -debug turn on debug logging -e string - bngblaster executable (default "/usr/sbin/bngblaster") + bngblaster executable (default "/usr/bin/bngblaster") + -interfaces-api + enable the interfaces endpoint (disable with -interfaces-api=false) (default true) + -schema string + path to the bngblaster configuration JSON schema served on /api/v1/schema (default "/usr/share/bngblaster/bngblaster-config.json") + -ui + enable the embedded web UI (experimental, disable with -ui=false) (default true) -upload - allow file upload + enable file upload (disable with -upload=false) (default true) +``` + +## Configuration + +### Command line + +All options above can be passed directly on the command line when running +`bngblasterctrl` manually. + +### systemd service + +When installed via the debian package, the service is started by +systemd and does not take command-line arguments directly. Instead, flags are +configured through `/etc/default/rtbrick-bngblasterctrl`, which is sourced by +the unit as an `EnvironmentFile` and expanded into `ExecStart` via the +`BNGBLASTERCTRL_OPTS` variable: + +``` +# /etc/default/rtbrick-bngblasterctrl +BNGBLASTERCTRL_OPTS="-addr :8080 -d /var/bngblaster" ``` +After editing the file, apply the change with: + +``` +$ sudo systemctl restart rtbrick-bngblasterctrl +``` + +This file is preserved across package upgrades and is the recommended way to +configure the service; editing the unit file directly (e.g. via +`systemctl edit rtbrick-bngblasterctrl`) also works but is not required. + +A fresh install enables and starts the service. Upgrades keep whether the +service is enabled and only restart it if it was running. + +The unit applies a conservative systemd sandbox: `/usr`, `/boot`, `/efi` and +`/etc` are read-only, `/home` and `/root` are read-only, `/tmp` is private to +the service, and kernel modules, kernel logs, cgroups, the clock and the +hostname cannot be changed. The config folder (`-d`) must therefore live +outside those paths (the default `/var/bngblaster` is fine). If a setup needs +more, relax individual settings with `systemctl edit rtbrick-bngblasterctrl`. + +Note that the `bngblaster` instances run inside the service's control group, +so stopping or restarting the service (including through a package upgrade) +also stops every running test instance. + +## Experimental Web UI + +The controller ships with an embedded, experimental web UI for creating and +observing test instances without calling the REST API directly. It is +**enabled by default**, along with the two additional endpoints it depends +on: + +* `-ui` — serves the web UI on `/` +* `-interfaces-api` — serves `/api/v1/interfaces`, used by the web UI to + populate the host network interface dropdown when creating a new instance +* `-upload` — enables the `/api/v1/instances/{instance_name}/_upload` + endpoint, used by the web UI (and the REST API) to upload files into a + test instance + +Each of them can be disabled individually by setting the flag to `false`, +e.g. to run a REST-only controller: + +``` +$ /usr/local/bin/bngblasterctrl -ui=false -interfaces-api=false -upload=false +``` + +or, for the systemd-installed service, in `/etc/default/rtbrick-bngblasterctrl`: + +``` +BNGBLASTERCTRL_OPTS="-ui=false -interfaces-api=false -upload=false" +``` + +Note that the web UI needs the interfaces and upload endpoints for some of +its features, so disabling them while keeping `-ui` enabled leaves those +parts of the UI non-functional. + +With the defaults, open `http://:/` in a browser. As the UI is experimental, +expect rough edges, and only expose the controller on networks you trust, since none of +these endpoints require authentication yet. + +## Security + +The REST API and web UI do not require authentication yet, so restrict who +can reach the port (bind `-addr` to a management address, firewall it, or +tunnel through SSH). On top of that, the controller: + +* rejects state-changing requests (`PUT`, `POST`, `DELETE`) that a browser + sends on behalf of another site (checked via the `Sec-Fetch-Site` and + `Origin` headers), so a malicious web page cannot drive the controller + through the browser of someone on the lab network. Clients such as curl or + scripts send neither header and are not affected; +* with `-allowed-hosts`, rejects requests addressed to any other host name, + which prevents DNS rebinding attacks from reading API responses. IP + addresses and `localhost` are always accepted. Set it to the names used to + reach the controller, e.g. `-allowed-hosts lab01,lab01.example.com`; +* sends `X-Frame-Options`, a restrictive `Content-Security-Policy` and + related headers on every response; +* only accepts a `stream_config` (`_start`) that lies inside the instance + folder, since bngblaster reads it as root. Upload the file into the + instance instead of referencing it elsewhere on the host; +* limits request sizes (32 MB per configuration, 4000 MB per upload) and + rejects uploads larger than the free disk space; +* logs the client address of every request and of each lifecycle change. + ## License BNG Blaster is licensed under the BSD 3-Clause License, which means that you are free to get and use it for @@ -51,7 +187,7 @@ See the LICENSE file for more details. ## Copyright -Copyright (C) 2020-2025, RtBrick, Inc. +Copyright (C) 2020-2026, RtBrick, Inc. ## Contact diff --git a/cmd/bngblasterctrl/bngblasterctrld.go b/cmd/bngblasterctrl/bngblasterctrld.go index 34d29c3..5b8c412 100644 --- a/cmd/bngblasterctrl/bngblasterctrld.go +++ b/cmd/bngblasterctrl/bngblasterctrld.go @@ -1,10 +1,15 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. package main import ( + "context" + "errors" "flag" "io" "net/http" "os" + "strings" "time" "github.com/rs/zerolog" @@ -21,7 +26,13 @@ func main() { addr := flag.String("addr", ":8001", "HTTP network address") directory := flag.String("d", controller.DefaultConfigFolder, "config folder") executable := flag.String("e", controller.DefaultExecutable, "bngblaster executable") - upload := flag.Bool("upload", false, "allow file upload") + upload := flag.Bool("upload", true, "enable file upload (disable with -upload=false)") + ui := flag.Bool("ui", true, "enable the embedded web UI (experimental, disable with -ui=false)") + interfacesAPI := flag.Bool("interfaces-api", true, "enable the interfaces endpoint (disable with -interfaces-api=false)") + schema := flag.String("schema", server.DefaultSchemaPath, "path to the bngblaster configuration JSON schema served on /api/v1/schema") + allowedHosts := flag.String("allowed-hosts", "", + "comma-separated host names clients may use to reach the controller, against DNS rebinding "+ + "(IP addresses and localhost are always allowed; empty allows any host)") // logging debug := flag.Bool("debug", false, "turn on debug logging") @@ -37,15 +48,31 @@ func main() { controller.WithConfigFolder(*directory), controller.WithExecutable(*executable), controller.WithUpload(*upload)) - srv := server.NewServer(repo) + srv := server.NewServer(repo, + server.WithUI(*ui), + server.WithInterfacesAPI(*interfacesAPI), + server.WithSchemaPath(*schema), + server.WithAllowedHosts(splitList(*allowedHosts))) srv.Version = Version serve(*addr, srv) } +// splitList splits a comma-separated flag value, dropping empty entries. +func splitList(value string) []string { + var items []string + for item := range strings.SplitSeq(value, ",") { + if item = strings.TrimSpace(item); item != "" { + items = append(items, item) + } + } + return items +} + func serve(addr string, handler http.Handler) { const idleTimeout = time.Second * 80 const writeTimeout = time.Second * 40 const readHeaderTimeout = time.Second * 40 + const shutdownTimeout = time.Second * 30 srv := &http.Server{ Addr: addr, Handler: handler, @@ -56,26 +83,59 @@ func serve(addr string, handler http.Handler) { log.Info().Msgf("Starting server on %s\n", addr) sig, err := daemonize.Daemonize(func() error { return srv.ListenAndServe() }) - if err != nil { + if err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatal().Err(err).Send() } log.Info().Msgf("Shutdown server on signal %s\n", sig) + + // Let in-flight requests (e.g. a start/stop call or a running download) + // finish instead of cutting them off; systemd's default stop timeout is + // 90s, so stay well below it. + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Warn().Err(err).Msg("graceful shutdown incomplete") + } } func initializeLogger(debug, console bool, color bool) { - var w io.Writer - w = os.Stderr + var out, errOut io.Writer = os.Stdout, os.Stderr if console { - w = zerolog.ConsoleWriter{ + out = zerolog.ConsoleWriter{ + Out: os.Stdout, + NoColor: !color, + TimeFormat: "2006-01-02 15:04:05 MST", + } + errOut = zerolog.ConsoleWriter{ Out: os.Stderr, NoColor: !color, TimeFormat: "2006-01-02 15:04:05 MST", } } - log.Logger = zerolog.New(w).With().Timestamp().Caller().Logger() + log.Logger = zerolog.New(levelSplitWriter{out: out, errOut: errOut}).With().Timestamp().Caller().Logger() zerolog.SetGlobalLevel(zerolog.InfoLevel) if debug { zerolog.SetGlobalLevel(zerolog.DebugLevel) } } + +// levelSplitWriter routes warn/error/fatal/panic records to errOut and +// everything below (info/debug/trace) to out, so the systemd unit's separate +// stdout/stderr log files actually separate normal activity from problems +// instead of funneling every record into one of them. +type levelSplitWriter struct { + out io.Writer + errOut io.Writer +} + +func (w levelSplitWriter) Write(p []byte) (int, error) { + return w.out.Write(p) +} + +func (w levelSplitWriter) WriteLevel(level zerolog.Level, p []byte) (int, error) { + if level >= zerolog.WarnLevel { + return w.errOut.Write(p) + } + return w.out.Write(p) +} diff --git a/debian/scripts/default/rtbrick-bngblasterctrl b/debian/scripts/default/rtbrick-bngblasterctrl new file mode 100644 index 0000000..e2b1f26 --- /dev/null +++ b/debian/scripts/default/rtbrick-bngblasterctrl @@ -0,0 +1,12 @@ +# Configuration for the rtbrick-bngblasterctrl systemd service. +# +# Set BNGBLASTERCTRL_OPTS to any combination of the bngblasterctrl +# command-line flags. Run "bngblasterctrl -h" for the full list. +# +# Examples: +# BNGBLASTERCTRL_OPTS="-addr :8080 -d /var/bngblaster" +# BNGBLASTERCTRL_OPTS="-ui=false -interfaces-api=false -upload=false" +# BNGBLASTERCTRL_OPTS="-schema /opt/bngblaster/bngblaster-config.json" +# BNGBLASTERCTRL_OPTS="-allowed-hosts lab01,lab01.example.com" +# +#BNGBLASTERCTRL_OPTS="" diff --git a/debian/scripts/logrotate/rtbrick-bngblasterctrl b/debian/scripts/logrotate/rtbrick-bngblasterctrl new file mode 100644 index 0000000..6c0bf45 --- /dev/null +++ b/debian/scripts/logrotate/rtbrick-bngblasterctrl @@ -0,0 +1,12 @@ +/var/log/rtbrick-bngblasterctrl-service-err.log +/var/log/rtbrick-bngblasterctrl-service-out.log { + su root root + size 10M + rotate 10 + compress + delaycompress + missingok + notifempty + copytruncate + create 0644 root root +} diff --git a/debian/scripts/postinstall.sh b/debian/scripts/postinstall.sh old mode 100644 new mode 100755 index e49ba21..54f2e92 --- a/debian/scripts/postinstall.sh +++ b/debian/scripts/postinstall.sh @@ -1,7 +1,22 @@ #!/bin/bash -cat <:8001/ +WARNING: the web UI and REST API do not require authentication yet; +restrict access to the port or disable features as described in +/etc/default/rtbrick-bngblasterctrl +MSG +else + # Upgrade: pick up the new binary without touching the enabled/disabled + # state the administrator chose, and only if the service was running. + systemctl try-restart rtbrick-bngblasterctrl +fi +exit 0 diff --git a/debian/scripts/postremove.sh b/debian/scripts/postremove.sh new file mode 100755 index 0000000..f502e17 --- /dev/null +++ b/debian/scripts/postremove.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# The unit file is gone by now, so let systemd forget about it. +if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then + systemctl daemon-reload + systemctl reset-failed rtbrick-bngblasterctrl 2>/dev/null +fi +exit 0 diff --git a/debian/scripts/preremove.sh b/debian/scripts/preremove.sh old mode 100644 new mode 100755 index b1863c6..b78eb35 --- a/debian/scripts/preremove.sh +++ b/debian/scripts/preremove.sh @@ -1,6 +1,8 @@ #!/bin/bash -systemctl stop rtbrick-bngblasterctrl; -systemctl disable rtbrick-bngblasterctrl; -rm /etc/systemd/system/rtbrick-bngblasterctrl.service; -systemctl daemon-reload; -systemctl reset-failed; \ No newline at end of file +# Debian also runs prerm with "upgrade"; the service is restarted by +# postinst in that case, so only stop and disable it on an actual removal. +if [ "$1" = "remove" ]; then + systemctl stop rtbrick-bngblasterctrl + systemctl disable rtbrick-bngblasterctrl +fi +exit 0 diff --git a/debian/scripts/systemd/rtbrick-bngblasterctrl.service b/debian/scripts/systemd/rtbrick-bngblasterctrl.service index 2b68f76..f5ee368 100644 --- a/debian/scripts/systemd/rtbrick-bngblasterctrl.service +++ b/debian/scripts/systemd/rtbrick-bngblasterctrl.service @@ -9,12 +9,34 @@ User=root Group=root Environment="USER=root" Environment="GROUP=root" -ExecStart=/usr/local/bin/bngblasterctrl +EnvironmentFile=-/etc/default/rtbrick-bngblasterctrl +ExecStart=/usr/local/bin/bngblasterctrl $BNGBLASTERCTRL_OPTS StandardOutput=file:/var/log/rtbrick-bngblasterctrl-service-out.log StandardError=file:/var/log/rtbrick-bngblasterctrl-service-err.log Restart=on-failure RestartSec=30s +# Sandboxing. The controller and the bngblaster instances it starts run as +# root with raw socket access, and the REST API has no authentication yet, +# so limit what a misused request can reach. Only settings that leave +# bngblaster's needs (raw sockets, netlink, /dev, /sys, hugepages, DPDK and +# AF_XDP) untouched are used; the config folder (-d) may live anywhere +# except /usr, /boot, /efi, /etc, /home and /root (one under /tmp is private +# to the service). Override with "systemctl edit" if a setup needs more. +# Makes /usr, /boot, /efi and /etc read-only. +ProtectSystem=full +# Home directories stay readable (e.g. a bngblaster built there, -e) but +# cannot be written. +ProtectHome=read-only +PrivateTmp=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +RestrictSUIDSGID=yes +LockPersonality=yes + [Install] WantedBy=multi-user.target Alias=rtbrick-bngblasterctrl.service \ No newline at end of file diff --git a/docs/embed.go b/docs/embed.go new file mode 100644 index 0000000..526052b --- /dev/null +++ b/docs/embed.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. + +// Package docs embeds this directory's OpenAPI/Swagger definition and its +// Swagger UI viewer page - the same files GitHub Pages serves at +// https://rtbrick.github.io/bngblaster-controller - so a running controller +// can also serve its own API documentation directly, with no separate +// deploy step and no risk of drifting from the spec actually shipped. +package docs + +import "embed" + +//go:embed swagger.yaml index.html +var Assets embed.FS diff --git a/docs/swagger.yaml b/docs/swagger.yaml index a819954..cda1758 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -6,6 +6,11 @@ info: It allows to manage instances in the sense that new instances can be created, started, stopped and also controlled via command socket of the BNG Blaster. + + The API does not require authentication. Every endpoint may answer `403` when a browser sends a + state-changing request (`PUT`, `POST`, `DELETE`) on behalf of another site (`Sec-Fetch-Site` / + `Origin` headers), or when the controller runs with `-allowed-hosts` and the `Host` header names + a host that is not listed. Non-browser clients such as curl are not affected by the former. contact: email: bngblaster@rtbrick.com url: https://github.com/rtbrick/bngblaster-controller @@ -54,6 +59,8 @@ paths: summary: List network interfaces. description: >- Get list of all host network interfaces. + + Enabled by default; not available (404) if the controller is started with `-interfaces-api=false`. responses: 200: description: ok @@ -75,6 +82,24 @@ paths: "mac": "aa:bb:cc:dd:ee:ff" } ] + /api/v1/schema: + get: + summary: BNG Blaster configuration JSON schema. + description: >- + Get the JSON schema used to render and validate the "New Instance" config editor in the web UI. + responses: + 200: + description: ok + content: + application/json: + schema: + type: object + 404: + description: not found, schema not available + content: + text/plain: + schema: + type: string /api/v1/instances: get: summary: List of all instances. @@ -191,6 +216,12 @@ paths: text/plain: schema: type: string + 413: + description: request entity too large, config exceeds 32 MB + content: + text/plain: + schema: + type: string 412: description: precondition failed, if instance is running content: @@ -230,6 +261,307 @@ paths: text/plain: schema: type: string + /api/v1/instances/{instance_name}/_overview: + get: + summary: Aggregated overview of a running instance. + description: >- + Get the results of the bngblaster control socket commands "session-counters", + "network-interfaces", "access-interfaces", "a10nsp-interfaces" and "test-info" + in a single, briefly cached response. Used by the web UI's instance overview. + + Every key is always present; a command that is unsupported or not applicable + for this instance yields null. + parameters: + - name: instance_name + description: instance name of the bngblaster + in: path + required: true + example: sample + schema: + type: string + responses: + 200: + description: ok + content: + application/json: + schema: + type: object + properties: + session-counters: + type: object + nullable: true + network-interfaces: + type: array + nullable: true + items: + type: object + access-interfaces: + type: array + nullable: true + items: + type: object + a10nsp-interfaces: + type: array + nullable: true + items: + type: object + test-info: + type: object + nullable: true + 404: + description: not found, instance does not exist + content: + text/plain: + schema: + type: string + 412: + description: precondition failed, if instance is not running + content: + text/plain: + schema: + type: string + 500: + description: internal server error + content: + text/plain: + schema: + type: string + /api/v1/instances/{instance_name}/_streams: + get: + summary: List traffic streams of a running instance. + description: >- + Get a paginated, optionally filtered view of the streams reported by the + bngblaster "stream-summary" control socket command. Used by the web UI's + virtual-scrolling stream table, which only ever requests the slice of rows + currently in (or near) its viewport. + parameters: + - name: instance_name + description: instance name of the bngblaster + in: path + required: true + example: sample + schema: + type: string + - name: offset + description: number of streams to skip + in: query + required: false + schema: + type: integer + default: 0 + - name: limit + description: maximum number of streams to return + in: query + required: false + schema: + type: integer + default: 50 + maximum: 500 + - name: session-id + in: query + required: false + schema: + type: integer + - name: session-group-id + in: query + required: false + schema: + type: integer + - name: flow-id + in: query + required: false + schema: + type: integer + - name: flow-id-min + description: lower bound of an explicit flow-id range; requires flow-id-max + in: query + required: false + schema: + type: integer + - name: flow-id-max + description: upper bound of an explicit flow-id range; requires flow-id-min + in: query + required: false + schema: + type: integer + - name: name + in: query + required: false + schema: + type: string + - name: interface + in: query + required: false + schema: + type: string + - name: direction + in: query + required: false + schema: + type: string + - name: state + in: query + required: false + schema: + type: string + enum: + - verified + - bidirectional-verified + - pending + responses: + 200: + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/streamsResponse' + 404: + description: not found, instance does not exist + content: + text/plain: + schema: + type: string + 412: + description: precondition failed, if instance is not running + content: + text/plain: + schema: + type: string + 500: + description: internal server error + content: + text/plain: + schema: + type: string + /api/v1/instances/{instance_name}/_sessions: + get: + summary: List sessions of a running instance. + description: >- + Get a paginated, optionally filtered view of the sessions reported by the + bngblaster "session-summary" control socket command. Used by the web UI's + virtual-scrolling session table, which only ever requests the slice of rows + currently in (or near) its viewport. + parameters: + - name: instance_name + description: instance name of the bngblaster + in: path + required: true + example: sample + schema: + type: string + - name: offset + description: number of sessions to skip + in: query + required: false + schema: + type: integer + default: 0 + - name: limit + description: maximum number of sessions to return + in: query + required: false + schema: + type: integer + default: 50 + maximum: 500 + - name: session-id + in: query + required: false + schema: + type: integer + - name: session-group-id + in: query + required: false + schema: + type: integer + - name: session-id-min + description: lower bound of an explicit session-id range; requires session-id-max + in: query + required: false + schema: + type: integer + - name: session-id-max + description: upper bound of an explicit session-id range; requires session-id-min + in: query + required: false + schema: + type: integer + responses: + 200: + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/sessionsResponse' + 404: + description: not found, instance does not exist + content: + text/plain: + schema: + type: string + 412: + description: precondition failed, if instance is not running + content: + text/plain: + schema: + type: string + 500: + description: internal server error + content: + text/plain: + schema: + type: string + /api/v1/instances/{instance_name}/_logs: + get: + summary: Tail the log of a running instance. + description: >- + Poll for log lines appended to the instance's run.log since a previous + call. Pass the "next_offset" from the previous response as the "offset" + query parameter to only receive newly appended lines. If the instance was + never started with logging enabled the response is an empty, EOF result + rather than an error. + parameters: + - name: instance_name + description: instance name of the bngblaster + in: path + required: true + example: sample + schema: + type: string + - name: offset + description: byte offset into run.log to read from + in: query + required: false + schema: + type: integer + default: 0 + - name: limit + description: maximum number of bytes to read + in: query + required: false + schema: + type: integer + default: 65536 + maximum: 1048576 + responses: + 200: + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/logsResponse' + 404: + description: not found, instance does not exist + content: + text/plain: + schema: + type: string + 500: + description: internal server error + content: + text/plain: + schema: + type: string /api/v1/instances/{instance_name}/_start: post: summary: Start an instance @@ -288,10 +620,9 @@ paths: - tcp - lag - dpdk + - af_xdp - packet - http - - timer - - timer-detail pcap_capture: description: allows to write a PCAP file type: boolean @@ -303,7 +634,10 @@ paths: type: integer deprecated: true stream_config: - description: specifies an optional stream configuration file (absolute path) + description: >- + specifies an optional stream configuration file inside the instance's directory + (upload it there first). A relative path is resolved against the instance's + directory; an absolute path must point into it. Any other path is rejected with 400. type: string metric_flags: description: flags that allows to specify what is exposed as metric @@ -328,11 +662,17 @@ paths: 204: description: no content, the instance was started 400: - description: bad request, body not parsable + description: bad request, body not parsable or stream_config outside the instance directory content: text/plain: schema: type: string + 413: + description: request entity too large, body exceeds 1 MB + content: + application/json: + schema: + type: object 404: description: not found, if instance does not exist content: @@ -437,6 +777,12 @@ paths: text/plain: schema: type: string + 413: + description: request entity too large, body exceeds 1 MB + content: + application/json: + schema: + type: object 412: description: precondition failed, if instance is not running or not existent content: @@ -449,6 +795,59 @@ paths: text/plain: schema: type: string + /api/v1/instances/{instance_name}/_files: + get: + summary: List downloadable files. + description: >- + List the files present in an instance's config folder, used by the web UI's + download view. + parameters: + - name: instance_name + description: instance name of the parsable + in: path + required: true + example: sample + schema: + type: string + responses: + 200: + description: ok + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/instanceFile' + 404: + description: not found, instance does not exist + /api/v1/instances/{instance_name}/_files/{file_name}: + get: + summary: Download one of the files listed by _files. + description: >- + Serves a single file out of an instance's config folder. Unlike the fixed-name + route registered for the well-known result files, this accepts any file name + (e.g. user-uploaded files) since it only ever downloads names the _files + endpoint itself just listed. + parameters: + - name: instance_name + description: instance name of the parsable + in: path + required: true + example: sample + schema: + type: string + - name: file_name + description: name of the file to download, as returned by _files + in: path + required: true + example: config.json + schema: + type: string + responses: + 200: + description: ok, with the content type applicable for the specific file ending. + 404: + description: not found, instance or file does not exist /api/v1/instances/{instance_name}/{file_name}: get: summary: Download one of the output files. @@ -487,9 +886,10 @@ paths: summary: Upload files. description: >- This API endpoint allows files to be uploaded into the test instance directory, - with a limit of 4GB per file. By default, file upload is disabled and must be - explicitly enabled by starting the controller with the-upload flag. + with a limit of 4GB per file. + The files the controller itself manages for a run (`run.pid`, `run.sock`, `run.json`, + `run.log`, `run_report.json`, `run.pcap`, `run.stdout`, `run.stderr`) cannot be uploaded. **Example:** `curl --location --request POST 'http://:/api/v1/instances//_upload' --form 'file=@'` @@ -505,16 +905,259 @@ paths: 200: description: ok, upload success 400: - description: error retrieving file + description: error retrieving file, invalid or reserved filename 403: - description: forbidden, controller not started with upload flag + description: forbidden, upload disabled (controller started with `-upload=false`) 413: - description: file to large (> 4GB) + description: file too large (> 4000 MB) 500: description: internal server error + 507: + description: insufficient storage, the file does not fit on the controller's disk components: schemas: + streamSummaryStream: + type: object + properties: + flow-id: + type: integer + name: + type: string + type: + type: string + sub-type: + type: string + direction: + type: string + enabled: + type: boolean + active: + type: boolean + verified: + type: boolean + interface: + type: string + tx-packets: + type: integer + tx-bytes: + type: integer + rx-packets: + type: integer + rx-bytes: + type: integer + rx-loss: + type: integer + tx-pps: + type: integer + rx-pps: + type: integer + session-id: + type: integer + session-traffic: + type: boolean + example: + { + "flow-id": 1, + "name": "stream1", + "type": "ipv4", + "sub-type": "raw", + "direction": "upstream", + "enabled": true, + "active": true, + "verified": true, + "interface": "bblA", + "tx-packets": 1000, + "tx-bytes": 64000, + "rx-packets": 1000, + "rx-bytes": 64000, + "rx-loss": 0, + "tx-pps": 100, + "rx-pps": 100, + "session-id": 1, + "session-traffic": false + } + streamsResponse: + type: object + properties: + total: + description: total number of streams matching the filters + type: integer + offset: + type: integer + limit: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/streamSummaryStream' + example: + { + "total": 1, + "offset": 0, + "limit": 50, + "items": [ + { + "flow-id": 1, + "name": "stream1", + "type": "ipv4", + "sub-type": "raw", + "direction": "upstream", + "enabled": true, + "active": true, + "verified": true, + "interface": "bblA", + "tx-packets": 1000, + "tx-bytes": 64000, + "rx-packets": 1000, + "rx-bytes": 64000, + "rx-loss": 0, + "tx-pps": 100, + "rx-pps": 100, + "session-id": 1, + "session-traffic": false + } + ] + } + sessionSummarySession: + type: object + properties: + type: + type: string + session-id: + type: integer + pppoe-session-id: + type: integer + session-state: + type: string + flapped: + type: integer + interface: + type: string + outer-vlan: + type: integer + inner-vlan: + type: integer + mac: + type: string + server-mac: + type: string + username: + type: string + ipv4-address: + type: string + lcp-state: + type: string + ipcp-state: + type: string + ip6cp-state: + type: string + dhcpv6-state: + type: string + tx-packets: + type: integer + rx-packets: + type: integer + example: + { + "type": "pppoe", + "session-id": 1, + "pppoe-session-id": 1, + "session-state": "Established", + "flapped": 0, + "interface": "bblA", + "outer-vlan": 1, + "inner-vlan": 1, + "mac": "aa:bb:cc:dd:ee:ff", + "server-mac": "ff:ee:dd:cc:bb:aa", + "username": "user1@rtbrick.com", + "ipv4-address": "10.100.128.0", + "lcp-state": "Opened", + "ipcp-state": "Opened", + "ip6cp-state": "Opened", + "dhcpv6-state": "", + "tx-packets": 1000, + "rx-packets": 1000 + } + sessionsResponse: + type: object + properties: + total: + description: total number of sessions matching the filters + type: integer + offset: + type: integer + limit: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/sessionSummarySession' + example: + { + "total": 1, + "offset": 0, + "limit": 50, + "items": [ + { + "type": "pppoe", + "session-id": 1, + "pppoe-session-id": 1, + "session-state": "Established", + "flapped": 0, + "interface": "bblA", + "outer-vlan": 1, + "inner-vlan": 1, + "mac": "aa:bb:cc:dd:ee:ff", + "server-mac": "ff:ee:dd:cc:bb:aa", + "username": "user1@rtbrick.com", + "ipv4-address": "10.100.128.0", + "lcp-state": "Opened", + "ipcp-state": "Opened", + "ip6cp-state": "Opened", + "dhcpv6-state": "", + "tx-packets": 1000, + "rx-packets": 1000 + } + ] + } + logsResponse: + type: object + properties: + offset: + description: byte offset the returned lines started at + type: integer + next_offset: + description: pass this as the "offset" query parameter on the next poll + type: integer + eof: + description: true if next_offset has reached the current end of the log file + type: boolean + lines: + type: array + items: + type: string + example: + { + "offset": 0, + "next_offset": 128, + "eof": true, + "lines": [ + "2025-01-01 00:00:00.000 INFO: bngblaster started" + ] + } + instanceFile: + type: object + properties: + name: + type: string + size: + type: integer + example: + { + "name": "run.log", + "size": 1024 + } commandResponse: type: object properties: diff --git a/go.mod b/go.mod index 2033da0..5b18c8d 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/gorilla/mux v1.8.0 github.com/prometheus/client_golang v1.12.2 github.com/rs/zerolog v1.27.0 - github.com/stretchr/testify v1.4.0 + github.com/stretchr/testify v1.12.1 ) require ( @@ -15,7 +15,6 @@ require ( github.com/andybalholm/brotli v1.0.4 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/structs v1.0.0 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/go-querystring v1.0.0 // indirect @@ -25,7 +24,6 @@ require ( github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.14 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/common v0.35.0 // indirect github.com/prometheus/procfs v0.7.3 // indirect @@ -38,9 +36,9 @@ require ( github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 // indirect github.com/yudai/gojsondiff v1.0.0 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect google.golang.org/protobuf v1.33.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect moul.io/http2curl v1.0.1-0.20190925090545-5cd742060b0e // indirect ) diff --git a/go.sum b/go.sum index 127ad2b..ba6c38f 100644 --- a/go.sum +++ b/go.sum @@ -58,7 +58,6 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -171,10 +170,8 @@ github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47e github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= @@ -199,7 +196,6 @@ github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1Cpa github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= @@ -240,8 +236,9 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.27.0/go.mod h1:cmWIqlu99AO/RKcp1HWaViTqc57FswJOfYYdPJBl8BA= @@ -270,6 +267,8 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -546,7 +545,6 @@ google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHh gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..fcb016e Binary files /dev/null and b/logo.png differ diff --git a/pkg/controller/errors.go b/pkg/controller/errors.go index 96f63bb..aac375f 100644 --- a/pkg/controller/errors.go +++ b/pkg/controller/errors.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BSD-3-Clause -// Copyright (C) 2020-2025, RtBrick, Inc. +// Copyright (C) 2020-2026, RtBrick, Inc. package controller // BlasterControllerError represents an blaster error. @@ -17,4 +17,7 @@ var ( ErrBlasterRunning = &BlasterControllerError{"blaster instance is running"} // ErrBlasterNotRunning there is no BlasterInstance running. ErrBlasterNotRunning = &BlasterControllerError{"blaster instance is not running"} + // ErrInvalidStreamConfig the stream configuration file is outside the + // instance folder. + ErrInvalidStreamConfig = &BlasterControllerError{"stream config must be a file inside the instance folder"} ) diff --git a/pkg/controller/model.go b/pkg/controller/model.go index d962b28..ca3ff8d 100644 --- a/pkg/controller/model.go +++ b/pkg/controller/model.go @@ -1,8 +1,12 @@ // SPDX-License-Identifier: BSD-3-Clause -// Copyright (C) 2020-2025, RtBrick, Inc. +// Copyright (C) 2020-2026, RtBrick, Inc. package controller +import "context" + //go:generate moq -out repositorymock.go . Repository +// moq cannot emit a license header, so prepend the one every Go file carries. +//go:generate sh -c "{ printf '// SPDX-License-Identifier: BSD-3-Clause\\n// Copyright (C) 2020-2026, RtBrick, Inc.\\n'; cat repositorymock.go; } > repositorymock.go.tmp && mv repositorymock.go.tmp repositorymock.go" // Repository for managing the bng blaster. type Repository interface { @@ -25,13 +29,27 @@ type Repository interface { // Running checks if a bngblaster instance is running. Running(name string) bool // Start the bngblaster instance with the given running configuration. - Start(name string, runningConfig RunningConfig) error + // It blocks until the instance is known to have come up or to have + // failed (see DefaultRepository.Start); ctx bounds that wait, so a + // caller whose client has gone away does not keep waiting. + Start(ctx context.Context, name string, runningConfig RunningConfig) error // Stop sends a SIGINT to the instance Stop(name string) // Kill sends a SIGKILL to the instance Kill(name string) // Command sends a request to the unix socket. Command(name string, command SocketCommand) ([]byte, error) + // Files lists the files present in an instance's config folder, for use + // by the web UI's downloads view. Internal run-control artifacts (the + // pid file and control socket) are excluded. + Files(name string) ([]InstanceFile, error) +} + +// InstanceFile describes one downloadable file inside an instance's config +// folder. +type InstanceFile struct { + Name string `json:"name"` + Size int64 `json:"size"` } // RunningConfig start configuration for the bngblaster. @@ -44,7 +62,7 @@ type RunningConfig struct { // Logging specifies if logging is enabled Logging bool `json:"logging"` // LoggingFlags flags that allows to specify what is logged - // Allowed values: debug|error|igmp|io|pppoe|info|pcap|ip|loss|l2tp|dhcp|isis|ospf|ldp|bgp|tcp|lag|dpdk|packet|http|timer|timer-detail + // Allowed values: debug|error|igmp|io|pppoe|info|pcap|ip|loss|l2tp|dhcp|isis|ospf|ldp|bgp|tcp|lag|dpdk|af_xdp|packet|http|timer|timer-detail LoggingFlags []string `json:"logging_flags"` // PCAPCapture allows to write a pcap file PCAPCapture bool `json:"pcap_capture"` @@ -52,7 +70,9 @@ type RunningConfig struct { PPPoESessionCount int `json:"pppoe_session_count"` // SessionCount overwrites the session count from config SessionCount int `json:"session_count"` - // StreamConfig specifies an optional stream configuration file (absolute path) + // StreamConfig specifies an optional stream configuration file inside + // the instance's directory. A relative path is resolved against it; an + // absolute path must point into it (see streamConfigPath). StreamConfig string `json:"stream_config"` // MetricFlags flags that allows to specify instance metrics to be reported // Allowed values: session_counters|interfaces|access_interfaces|network_interfaces|a10nsp_interfaces|streams @@ -229,20 +249,63 @@ type A10nspInterfacesResponse struct { } `json:"a10nsp-interfaces"` } +// StreamSummaryStream describes a single stream as reported by the +// stream-summary socket command. +type StreamSummaryStream struct { + FlowId int `json:"flow-id"` + Name string `json:"name"` + Type string `json:"type"` + SubType string `json:"sub-type"` + Direction string `json:"direction"` + Enabled bool `json:"enabled"` + Active bool `json:"active"` + Verified bool `json:"verified"` + Interface string `json:"interface"` + TxPackets int `json:"tx-packets"` + TxBytes int `json:"tx-bytes"` + RxPackets int `json:"rx-packets"` + RxBytes int `json:"rx-bytes"` + RxLoss int `json:"rx-loss"` + TxPPS int `json:"tx-pps"` + RxPPS int `json:"rx-pps"` + SessionId int `json:"session-id"` + SessionTraffic bool `json:"session-traffic"` +} + // StreamSummaryResponse response for stream-summary socket command. type StreamSummaryResponse struct { - Code int `json:"code"` - Streams []struct { - FlowId int `json:"flow-id"` - Name string `json:"name"` - Type string `json:"type"` - SubType string `json:"sub-type"` - Direction string `json:"direction"` - TxPackets int `json:"tx-packets"` - TxBytes int `json:"tx-bytes"` - RxPackets int `json:"rx-packets"` - RxBytes int `json:"rx-bytes"` - RxLoss int `json:"rx-loss"` - SessionId int `json:"session-id"` - } `json:"stream-summary"` + Code int `json:"code"` + Streams []StreamSummaryStream `json:"stream-summary"` +} + +// SessionSummarySession describes a single session as reported by the +// session-summary socket command. Not every field is populated for every +// session type (e.g. dhcpv6-state/ip6cp-state only apply to some sessions), +// which is fine here since it only backs the summary table - the full, +// untyped session-info response backs the session detail view. +type SessionSummarySession struct { + Type string `json:"type"` + SessionId int `json:"session-id"` + PPPoESessionId int `json:"pppoe-session-id"` + SessionState string `json:"session-state"` + Flapped int `json:"flapped"` + Interface string `json:"interface"` + OuterVlan int `json:"outer-vlan"` + InnerVlan int `json:"inner-vlan"` + MAC string `json:"mac"` + ServerMAC string `json:"server-mac"` + Username string `json:"username"` + IPv4Address string `json:"ipv4-address"` + LCPState string `json:"lcp-state"` + IPCPState string `json:"ipcp-state"` + IP6CPState string `json:"ip6cp-state"` + DHCPv6State string `json:"dhcpv6-state"` + TxPackets int `json:"tx-packets"` + RxPackets int `json:"rx-packets"` +} + +// SessionSummaryResponse response for session-summary socket command. +type SessionSummaryResponse struct { + Code int `json:"code"` + Sessions []SessionSummarySession `json:"session-summary"` } diff --git a/pkg/controller/options.go b/pkg/controller/options.go index 094e9d5..c40af9f 100644 --- a/pkg/controller/options.go +++ b/pkg/controller/options.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BSD-3-Clause -// Copyright (C) 2020-2025, RtBrick, Inc. +// Copyright (C) 2020-2026, RtBrick, Inc. package controller // DefaultRepositoryOption helps to configure the Repository with options. @@ -19,7 +19,7 @@ func WithExecutable(executable string) DefaultRepositoryOption { } } -// WithUpload is the option to allow file upload. +// WithUpload is the option to allow file upload. Disabled by default. func WithUpload(upload bool) DefaultRepositoryOption { return func(r *DefaultRepository) { r.allow_upload = upload diff --git a/pkg/controller/process.go b/pkg/controller/process.go index 8c7880b..a1c042d 100644 --- a/pkg/controller/process.go +++ b/pkg/controller/process.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BSD-3-Clause -// Copyright (C) 2020-2025, RtBrick, Inc. +// Copyright (C) 2020-2026, RtBrick, Inc. package controller import ( @@ -15,16 +15,25 @@ import ( var ExecCommand = exec.Command // RunCommand runs the command +// dir working directory the command is started in; relative file paths +// referenced by the command (e.g. a bngblaster config's isis mrt-file or +// bgp raw-update-file) resolve against this directory. Empty inherits the +// caller's own working directory. // pidFile file that should be written with the pid // stdFile file that should be written with the stdout // errFile file that should be written with the stderr // args first argument will be the command to execute, all the rest are arguments that are used for this command. -func RunCommand(pidFile string, stdFile string, errFile string, args ...string) (chan bool, error) { +// The returned channel receives the command's exit error (nil on a clean +// exit) exactly once, once the process has terminated; it is buffered so a +// caller that stops waiting (e.g. after a startup grace period) never +// leaks the reporting goroutine. +func RunCommand(dir string, pidFile string, stdFile string, errFile string, args ...string) (chan error, error) { if len(args) == 0 { return nil, fmt.Errorf("at least one argument need to be specified") } log.Info().Str("command", strings.Join(args, " ")).Msg("start Command") cmd := ExecCommand(args[0], args[1:]...) + cmd.Dir = dir stdout, err := os.OpenFile(stdFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, permission) if err != nil { @@ -44,12 +53,13 @@ func RunCommand(pidFile string, stdFile string, errFile string, args ...string) pid := cmd.Process.Pid _ = os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", pid)), permission) - done := make(chan bool) + done := make(chan error, 1) go func() { - _ = cmd.Wait() + waitErr := cmd.Wait() _ = stdout.Close() _ = stderr.Close() _ = os.Remove(pidFile) + done <- waitErr close(done) log.Info().Str("command", strings.Join(args, " ")).Msg("stopped Command") }() diff --git a/pkg/controller/process_test.go b/pkg/controller/process_test.go index a7463bf..e34c80a 100644 --- a/pkg/controller/process_test.go +++ b/pkg/controller/process_test.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BSD-3-Clause -// Copyright (C) 2020-2025, RtBrick, Inc. +// Copyright (C) 2020-2026, RtBrick, Inc. package controller import ( @@ -89,7 +89,7 @@ func TestApplication_ExecCommand(t *testing.T) { defer func() { _ = os.Remove(stdoutFile) }() - done, err := RunCommand(pidFile, stdoutFile, stderrFile, tt.command...) + done, err := RunCommand("", pidFile, stdoutFile, stderrFile, tt.command...) if (err == nil) == tt.wantErr { t.Fatalf("RunCommand() error = %v, wantErr %v", err, tt.wantErr) } @@ -114,10 +114,32 @@ func TestApplication_runCommandNoTimeOut(t *testing.T) { defer func() { _ = os.Remove(stdoutFile) }() - _, err := RunCommand(pidFile, stdoutFile, stderrFile, "sleep", "10") + _, err := RunCommand("", pidFile, stdoutFile, stderrFile, "sleep", "10") require.NoError(t, err) } +// TestApplication_RunCommand_WorkingDirectory verifies that a relative file +// reference in the command's arguments is resolved against dir, the way a +// bngblaster config referencing a relative isis mrt-file or bgp +// raw-update-file needs to resolve it against the instance folder rather +// than the controller's own working directory. +func TestApplication_RunCommand_WorkingDirectory(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(dir+"/relative.txt", []byte("hello"), 0o644)) + + localPidFile := dir + "/pid" + localStdoutFile := dir + "/out" + localStderrFile := dir + "/err" + + done, err := RunCommand(dir, localPidFile, localStdoutFile, localStderrFile, "cat", "relative.txt") + require.NoError(t, err) + require.NoError(t, <-done) + + got, err := os.ReadFile(localStdoutFile) + require.NoError(t, err) + require.Equal(t, "hello", string(got)) +} + func TestApplication_ExecCommand_Real(t *testing.T) { tcs := []struct { command []string @@ -138,7 +160,7 @@ func TestApplication_ExecCommand_Real(t *testing.T) { defer func() { _ = os.Remove(stdoutFile) }() - done, err := RunCommand(pidFile, stdoutFile, stderrFile, tt.command...) + done, err := RunCommand("", pidFile, stdoutFile, stderrFile, tt.command...) if (err == nil) == tt.wantErr { t.Fatalf("RunCommand() error = %v, wantErr %v", err, tt.wantErr) } diff --git a/pkg/controller/prom.go b/pkg/controller/prom.go index 54ca972..aeea8de 100644 --- a/pkg/controller/prom.go +++ b/pkg/controller/prom.go @@ -1,10 +1,9 @@ // SPDX-License-Identifier: BSD-3-Clause -// Copyright (C) 2020-2025, RtBrick, Inc. +// Copyright (C) 2020-2026, RtBrick, Inc. package controller import ( "encoding/json" - "fmt" "os" "path" "strconv" @@ -786,7 +785,6 @@ func (p *Prom) collectInstance(wg *sync.WaitGroup, instance string, ch chan<- pr file, err := os.Open(path) if err != nil { log.Warn().Msgf("failed to open %s: %s", path, err.Error()) - fmt.Println(err) return } diff --git a/pkg/controller/repository.go b/pkg/controller/repository.go index 62b1aef..74c97f1 100644 --- a/pkg/controller/repository.go +++ b/pkg/controller/repository.go @@ -1,8 +1,9 @@ // SPDX-License-Identifier: BSD-3-Clause -// Copyright (C) 2020-2025, RtBrick, Inc. +// Copyright (C) 2020-2026, RtBrick, Inc. package controller import ( + "context" "encoding/json" "errors" "fmt" @@ -10,7 +11,9 @@ import ( "net" "os" "path" + "path/filepath" "strconv" + "strings" "syscall" "time" ) @@ -20,7 +23,7 @@ const ( DefaultConfigFolder = "/var/bngblaster" // DefaultExecutable is the default executable for bngblaster. - DefaultExecutable = "/usr/sbin/bngblaster" + DefaultExecutable = "/usr/bin/bngblaster" // permission file and folder permissions to use. permission os.FileMode = 0o777 @@ -30,6 +33,16 @@ const ( bufferLength = 512 initialReceiveBufferLength = 20000 + // startupPollInterval is how often Start polls for the control socket + // while waiting to see whether bngblaster came up successfully. + startupPollInterval = 100 * time.Millisecond + // startupMaxWait bounds how long Start waits for the control socket to + // appear before giving up on detecting failure and reporting success + // anyway. A very large configuration can legitimately take a few + // seconds to come up, so this needs real headroom above the common + // "bad config, fails in milliseconds" case. + startupMaxWait = 30 * time.Second + // ConfigFilename configuration file of the blaster. ConfigFilename = "config.json" // runPidFilename file that contains the process id of the bngblaster instance if it is running. @@ -50,6 +63,19 @@ const ( RunStdOut = "run.stdout" ) +// IsRunFile reports whether name is one of the files the controller itself +// writes into an instance folder for a run. Uploads must never replace +// these: run.pid in particular decides which process _stop and _kill signal, +// and the controller runs as root. +func IsRunFile(name string) bool { + switch name { + case runPidFilename, RunSockFilename, RunConfigFilename, RunLogFilename, + RunReportFilename, RunPcapFilename, RunStdErr, RunStdOut: + return true + } + return false +} + // make sure the DefaultRepository implements UseRepository. var _ Repository = &DefaultRepository{} @@ -165,24 +191,37 @@ func (r *DefaultRepository) Exists(name string) bool { return true } +// pid returns the process id recorded in the instance's pid file. +// +// Only values above 1 are accepted: the pid is handed straight to kill(2), +// where 0 and negative values address whole process groups (-1 is every +// process) and 1 is init - none of which can ever be a bngblaster instance, +// whatever ended up in the file. +func (r *DefaultRepository) pid(name string) (int, bool) { + piddata, err := os.ReadFile(path.Join(r.configFolder, name, runPidFilename)) + if err != nil { + return 0, false + } + pid, err := strconv.Atoi(strings.TrimSpace(string(piddata))) + if err != nil || pid <= 1 { + return 0, false + } + return pid, true +} + // Running implements Repository. func (r *DefaultRepository) Running(name string) bool { - folder := path.Join(r.configFolder, name) - file := path.Join(folder, runPidFilename) + file := path.Join(r.configFolder, name, runPidFilename) if _, err := os.Stat(file); os.IsNotExist(err) { return false } - // Read in the pid file as a slice of bytes. - if piddata, err := os.ReadFile(file); err == nil { - // Convert the file contents to an integer. - if pid, err := strconv.Atoi(string(piddata)); err == nil { - // Look for the pid in the process list. - if process, err := os.FindProcess(pid); err == nil { - // Send the process a signal zero kill. - if err := process.Signal(syscall.Signal(0)); err == nil { - // We only get an error if the pid isn't running, or it's not ours. - return true - } + if pid, ok := r.pid(name); ok { + // Look for the pid in the process list. + if process, err := os.FindProcess(pid); err == nil { + // Send the process a signal zero kill. + if err := process.Signal(syscall.Signal(0)); err == nil { + // We only get an error if the pid isn't running, or it's not ours. + return true } } } @@ -191,13 +230,19 @@ func (r *DefaultRepository) Running(name string) bool { } // Start implements Repository. -func (r *DefaultRepository) Start(name string, runningConfig RunningConfig) error { +func (r *DefaultRepository) Start(ctx context.Context, name string, runningConfig RunningConfig) error { if !r.Exists(name) { return ErrBlasterNotExists } if r.Running(name) { return ErrBlasterRunning } + // Validate before touching any run file, so a rejected request leaves + // the previous run's report and logs in place. + params, err := r.commandlineParameters(name, runningConfig) + if err != nil { + return err + } if err := r.cleanupRunFiles(name); err != nil { return err } @@ -210,13 +255,65 @@ func (r *DefaultRepository) Start(name string, runningConfig RunningConfig) erro if err := os.WriteFile(file, config, permission); err != nil { return err } - params := r.commandlineParameters(name, runningConfig) - _, err = RunCommand( + // folder as the working directory lets bngblaster resolve any relative + // file reference in config.json (e.g. an isis mrt-file or a bgp + // raw-update-file) against the instance directory, which is also where + // uploaded files are stored. + done, err := RunCommand( + folder, path.Join(folder, runPidFilename), path.Join(folder, RunStdOut), path.Join(folder, RunStdErr), params...) - return err + if err != nil { + return err + } + + // bngblaster only creates its control socket once it has fully come up + // (config parsed and validated, interfaces set up); a bad configuration + // instead makes it print an error and exit - usually within + // milliseconds, but a very large configuration can take a few seconds + // to either come up or fail. So: wait for whichever happens first, + // bounded by startupMaxWait so this can never hang the request forever, + // and by ctx so a caller that has gone away (a disconnected HTTP client) + // stops the wait immediately instead of pinning a goroutine for it. + // + // Note that returning early never stops the instance: it has been + // spawned either way, and giving up on *observing* the outcome only + // means the caller has to ask for the status separately. + sockFile := path.Join(folder, RunSockFilename) + deadline := time.Now().Add(startupMaxWait) + ticker := time.NewTicker(startupPollInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + // Caller gave up waiting; the instance itself keeps running. + return nil + case waitErr := <-done: + if waitErr == nil { + // Exited on its own without an error before creating a + // socket: not the failure case this is guarding against. + return nil + } + stderrContent, _ := os.ReadFile(path.Join(folder, RunStdErr)) + msg := strings.TrimSpace(string(stderrContent)) + if msg == "" { + msg = waitErr.Error() + } + return fmt.Errorf("%s", msg) + case <-ticker.C: + if _, statErr := os.Stat(sockFile); statErr == nil { + return nil + } + if time.Now().After(deadline) { + // Still running, just hasn't created its socket yet after a + // generous wait: report success rather than blocking (or + // misreporting failure) any longer. + return nil + } + } + } } // Stop implements Repository. @@ -230,23 +327,16 @@ func (r *DefaultRepository) Kill(name string) { } func (r *DefaultRepository) sendSignal(name string, signal os.Signal) { - folder := path.Join(r.configFolder, name) - file := path.Join(folder, runPidFilename) - // Read in the pid file as a slice of bytes. - if piddata, err := os.ReadFile(file); err == nil { - // Convert the file contents to an integer. - if pid, err := strconv.Atoi(string(piddata)); err == nil { - // Look for the pid in the process list. - if process, err := os.FindProcess(pid); err == nil { - // Send the process a signal. - _ = process.Signal(signal) - return - } + if pid, ok := r.pid(name); ok { + // Look for the pid in the process list. + if process, err := os.FindProcess(pid); err == nil { + // Send the process a signal. + _ = process.Signal(signal) } } } -func (r *DefaultRepository) commandlineParameters(name string, runningConfig RunningConfig) []string { +func (r *DefaultRepository) commandlineParameters(name string, runningConfig RunningConfig) ([]string, error) { folder := path.Join(r.configFolder, name) var params []string params = append(params, r.executable) @@ -274,9 +364,42 @@ func (r *DefaultRepository) commandlineParameters(name string, runningConfig Run params = append(params, "-c", fmt.Sprintf("%d", runningConfig.PPPoESessionCount)) } if len(runningConfig.StreamConfig) > 0 { - params = append(params, "-T", runningConfig.StreamConfig) + streamConfig, err := streamConfigPath(folder, runningConfig.StreamConfig) + if err != nil { + return nil, err + } + params = append(params, "-T", streamConfig) } - return params + return params, nil +} + +// streamConfigPath resolves the stream configuration file of a start +// request and makes sure it lies inside the instance folder. +// +// bngblaster reads that file as root and its parse errors end up in the +// downloadable run.stderr, so an arbitrary path would let any API caller +// probe (and partly read) files anywhere on the host. Files a test needs +// can be uploaded into the instance folder instead. A relative path is +// resolved against the instance folder; an absolute one is accepted as long +// as it points into it, which keeps the paths the web UI suggests working. +func streamConfigPath(folder, streamConfig string) (string, error) { + resolved := streamConfig + if !path.IsAbs(resolved) { + resolved = path.Join(folder, resolved) + } + absFolder, err := filepath.Abs(folder) + if err != nil { + return "", err + } + absResolved, err := filepath.Abs(resolved) + if err != nil { + return "", err + } + rel, err := filepath.Rel(absFolder, absResolved) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, "../") { + return "", ErrInvalidStreamConfig + } + return resolved, nil } func (r *DefaultRepository) config(name string) ([]byte, error) { @@ -285,6 +408,30 @@ func (r *DefaultRepository) config(name string) ([]byte, error) { return os.ReadFile(file) } +// Files implements Repository. +func (r *DefaultRepository) Files(name string) ([]InstanceFile, error) { + if !r.Exists(name) { + return nil, ErrBlasterNotExists + } + folder := path.Join(r.configFolder, name) + entries, err := os.ReadDir(folder) + if err != nil { + return nil, err + } + files := make([]InstanceFile, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || entry.Name() == runPidFilename || entry.Name() == RunSockFilename { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, InstanceFile{Name: entry.Name(), Size: info.Size()}) + } + return files, nil +} + // Command implements Repository. func (r *DefaultRepository) Command(name string, command SocketCommand) ([]byte, error) { if !r.Exists(name) { diff --git a/pkg/controller/repository_test.go b/pkg/controller/repository_test.go index 008c1c0..8e05865 100644 --- a/pkg/controller/repository_test.go +++ b/pkg/controller/repository_test.go @@ -1,14 +1,18 @@ // SPDX-License-Identifier: BSD-3-Clause -// Copyright (C) 2020-2025, RtBrick, Inc. +// Copyright (C) 2020-2026, RtBrick, Inc. package controller import ( + "context" "encoding/json" "fmt" "net" "os" + "os/exec" "os/signal" "path" + "path/filepath" + "strconv" "strings" "syscall" "testing" @@ -163,6 +167,8 @@ func TestDefaultRepository_States(t *testing.T) { func TestDefaultRepository_commandlineParameters(t *testing.T) { const rootFolder = "td" + absRoot, err := filepath.Abs(rootFolder) + require.NoError(t, err) r := NewDefaultRepository(WithConfigFolder(rootFolder)) tests := []struct { name string @@ -173,7 +179,7 @@ func TestDefaultRepository_commandlineParameters(t *testing.T) { name: "default", runningConfig: RunningConfig{}, want: []string{ - "/usr/sbin/bngblaster", + "/usr/bin/bngblaster", "-C", "td/default/config.json", "-S", "td/default/run.sock", }, @@ -187,7 +193,7 @@ func TestDefaultRepository_commandlineParameters(t *testing.T) { PPPoESessionCount: 1000, }, want: []string{ - "/usr/sbin/bngblaster", + "/usr/bin/bngblaster", "-C", "td/all/config.json", "-S", "td/all/run.sock", "-J", "td/all/run_report.json", @@ -197,12 +203,52 @@ func TestDefaultRepository_commandlineParameters(t *testing.T) { "-P", "td/all/run.pcap", "-c", "1000", }, + }, { + name: "stream config relative path", + runningConfig: RunningConfig{ + StreamConfig: "streams.json", + }, + want: []string{ + "/usr/bin/bngblaster", + "-C", "td/stream config relative path/config.json", + "-S", "td/stream config relative path/run.sock", + "-T", "td/stream config relative path/streams.json", + }, + }, { + name: "stream config absolute path", + runningConfig: RunningConfig{ + StreamConfig: filepath.Join(absRoot, "stream config absolute path", "streams.json"), + }, + want: []string{ + "/usr/bin/bngblaster", + "-C", "td/stream config absolute path/config.json", + "-S", "td/stream config absolute path/run.sock", + "-T", filepath.Join(absRoot, "stream config absolute path", "streams.json"), + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, want := r.commandlineParameters(tt.name, tt.runningConfig), tt.want - require.Equal(t, want, got) + got, err := r.commandlineParameters(tt.name, tt.runningConfig) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestDefaultRepository_commandlineParameters_rejectsStreamConfigOutsideInstance(t *testing.T) { + // bngblaster reads the stream config as root, so it must not be usable + // to probe arbitrary files on the host. + r := NewDefaultRepository(WithConfigFolder("td")) + for _, streamConfig := range []string{ + "/etc/shadow", + "../other/streams.json", + "sub/../../streams.json", + ".", + } { + t.Run(streamConfig, func(t *testing.T) { + _, err := r.commandlineParameters("test", RunningConfig{StreamConfig: streamConfig}) + require.ErrorIs(t, err, ErrInvalidStreamConfig) }) } } @@ -240,7 +286,7 @@ func TestDefaultRepository_Start(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := r.Start(tt.name, tt.runningConfig); (err != nil) != tt.wantErr { + if err := r.Start(context.Background(), tt.name, tt.runningConfig); (err != nil) != tt.wantErr { t.Fatalf("Start() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { @@ -438,3 +484,117 @@ func waitSig(t *testing.T, c <-chan os.Signal, sig os.Signal) { } t.Fatalf("timeout after %v waiting for %v", settleTime, sig) } + +func TestDefaultRepository_Start_returnsWhenTheCallerGivesUp(t *testing.T) { + // A process that stays alive without ever creating a control socket: + // exactly the case Start waits out, up to startupMaxWait. + defaultExecCommand := ExecCommand + ExecCommand = func(command string, args ...string) *exec.Cmd { + return exec.Command("sleep", "10") + } + defer func() { ExecCommand = defaultExecCommand }() + + // Its own config folder: Start writes run files into the instance folder, + // and the checked-in td/ fixtures are shared with the other tests. + configFolder := t.TempDir() + folder := path.Join(configFolder, "instance") + require.NoError(t, os.MkdirAll(folder, 0o700)) + r := NewDefaultRepository(WithConfigFolder(configFolder), WithExecutable("test")) + + // The caller's HTTP client has gone away. The instance has been spawned + // either way; only the observation of its outcome is abandoned, so Start + // must return at once instead of blocking for the full startup window. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + started := time.Now() + done := make(chan error, 1) + go func() { done <- r.Start(ctx, "instance", RunningConfig{}) }() + + select { + case err := <-done: + require.NoError(t, err) + if waited := time.Since(started); waited >= 2*time.Second { + t.Fatalf("Start() waited %s: it ignored the cancelled context and "+ + "blocked on the process instead", waited) + } + case <-time.After(5 * time.Second): + t.Fatal("Start() ignored the cancelled context and kept waiting") + } + + // Leave no stray process behind. + if piddata, err := os.ReadFile(path.Join(folder, runPidFilename)); err == nil { + if pid, err := strconv.Atoi(string(piddata)); err == nil { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + } +} + +func TestDefaultRepository_ignoresPidsThatCannotBeAnInstance(t *testing.T) { + // 0 and negative pids address process groups in kill(2) and 1 is init; + // a pid file holding one of them (corrupt, or planted) must never be + // signalled. The negated pgid of the test itself is the case that is + // observable without root: kill(-pgid, 0) succeeds for our own group. + // Stop and Kill share pid(), but are not exercised with it here since a + // regression would interrupt the whole test run instead of failing it. + ownGroup := strconv.Itoa(-syscall.Getpgrp()) + for _, content := range []string{"0", "1", "-1", ownGroup, "", "abc"} { + t.Run(content, func(t *testing.T) { + folder := t.TempDir() + require.NoError(t, os.MkdirAll(path.Join(folder, "test"), permission)) + pidFile := path.Join(folder, "test", runPidFilename) + require.NoError(t, os.WriteFile(pidFile, []byte(content), permission)) + + r := NewDefaultRepository(WithConfigFolder(folder)) + require.False(t, r.Running("test")) + require.NoFileExists(t, pidFile, "a stale pid file is cleaned up") + }) + } +} + +func TestIsRunFile(t *testing.T) { + for _, name := range []string{ + runPidFilename, RunSockFilename, RunConfigFilename, RunLogFilename, + RunReportFilename, RunPcapFilename, RunStdErr, RunStdOut, + } { + require.True(t, IsRunFile(name), name) + } + // config.json and user files are legitimately replaced by uploads. + for _, name := range []string{ConfigFilename, "streams.json", "run.pid.bak"} { + require.False(t, IsRunFile(name), name) + } +} + +func TestDefaultRepository_Instances(t *testing.T) { + folder := t.TempDir() + require.NoError(t, os.MkdirAll(path.Join(folder, "a"), permission)) + require.NoError(t, os.MkdirAll(path.Join(folder, "b"), permission)) + require.NoError(t, os.WriteFile(path.Join(folder, "not-an-instance"), nil, permission)) + + require.Equal(t, []string{"a", "b"}, NewDefaultRepository(WithConfigFolder(folder)).Instances()) + + missing := NewDefaultRepository(WithConfigFolder(path.Join(folder, "missing"))) + require.Equal(t, []string{}, missing.Instances(), "a missing config folder is an empty list, not nil") +} + +func TestDefaultRepository_Files(t *testing.T) { + folder := t.TempDir() + instance := path.Join(folder, "test") + require.NoError(t, os.MkdirAll(path.Join(instance, "subdir"), permission)) + require.NoError(t, os.WriteFile(path.Join(instance, ConfigFilename), []byte("{}"), permission)) + require.NoError(t, os.WriteFile(path.Join(instance, RunLogFilename), []byte("log"), permission)) + require.NoError(t, os.WriteFile(path.Join(instance, runPidFilename), []byte("42"), permission)) + require.NoError(t, os.WriteFile(path.Join(instance, RunSockFilename), nil, permission)) + + r := NewDefaultRepository(WithConfigFolder(folder)) + files, err := r.Files("test") + require.NoError(t, err) + // The pid file and socket are internal and directories are skipped. + require.ElementsMatch(t, []InstanceFile{ + {Name: ConfigFilename, Size: 2}, + {Name: RunLogFilename, Size: 3}, + }, files) + + _, err = r.Files("missing") + require.ErrorIs(t, err, ErrBlasterNotExists) +} diff --git a/pkg/controller/repositorymock.go b/pkg/controller/repositorymock.go index 3021845..46336bb 100644 --- a/pkg/controller/repositorymock.go +++ b/pkg/controller/repositorymock.go @@ -1,9 +1,12 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. // Code generated by moq; DO NOT EDIT. // github.com/matryer/moq package controller import ( + "context" "sync" ) @@ -38,6 +41,9 @@ var _ Repository = &RepositoryMock{} // ExistsFunc: func(name string) bool { // panic("mock out the Exists method") // }, +// FilesFunc: func(name string) ([]InstanceFile, error) { +// panic("mock out the Files method") +// }, // InstancesFunc: func() []string { // panic("mock out the Instances method") // }, @@ -47,7 +53,7 @@ var _ Repository = &RepositoryMock{} // RunningFunc: func(name string) bool { // panic("mock out the Running method") // }, -// StartFunc: func(name string, runningConfig RunningConfig) error { +// StartFunc: func(ctx context.Context, name string, runningConfig RunningConfig) error { // panic("mock out the Start method") // }, // StopFunc: func(name string) { @@ -81,6 +87,9 @@ type RepositoryMock struct { // ExistsFunc mocks the Exists method. ExistsFunc func(name string) bool + // FilesFunc mocks the Files method. + FilesFunc func(name string) ([]InstanceFile, error) + // InstancesFunc mocks the Instances method. InstancesFunc func() []string @@ -91,7 +100,7 @@ type RepositoryMock struct { RunningFunc func(name string) bool // StartFunc mocks the Start method. - StartFunc func(name string, runningConfig RunningConfig) error + StartFunc func(ctx context.Context, name string, runningConfig RunningConfig) error // StopFunc mocks the Stop method. StopFunc func(name string) @@ -131,6 +140,11 @@ type RepositoryMock struct { // Name is the name argument value. Name string } + // Files holds details about calls to the Files method. + Files []struct { + // Name is the name argument value. + Name string + } // Instances holds details about calls to the Instances method. Instances []struct { } @@ -146,6 +160,8 @@ type RepositoryMock struct { } // Start holds details about calls to the Start method. Start []struct { + // Ctx is the ctx argument value. + Ctx context.Context // Name is the name argument value. Name string // RunningConfig is the runningConfig argument value. @@ -164,6 +180,7 @@ type RepositoryMock struct { lockDelete sync.RWMutex lockExecutable sync.RWMutex lockExists sync.RWMutex + lockFiles sync.RWMutex lockInstances sync.RWMutex lockKill sync.RWMutex lockRunning sync.RWMutex @@ -388,6 +405,38 @@ func (mock *RepositoryMock) ExistsCalls() []struct { return calls } +// Files calls FilesFunc. +func (mock *RepositoryMock) Files(name string) ([]InstanceFile, error) { + if mock.FilesFunc == nil { + panic("RepositoryMock.FilesFunc: method is nil but Repository.Files was just called") + } + callInfo := struct { + Name string + }{ + Name: name, + } + mock.lockFiles.Lock() + mock.calls.Files = append(mock.calls.Files, callInfo) + mock.lockFiles.Unlock() + return mock.FilesFunc(name) +} + +// FilesCalls gets all the calls that were made to Files. +// Check the length with: +// +// len(mockedRepository.FilesCalls()) +func (mock *RepositoryMock) FilesCalls() []struct { + Name string +} { + var calls []struct { + Name string + } + mock.lockFiles.RLock() + calls = mock.calls.Files + mock.lockFiles.RUnlock() + return calls +} + // Instances calls InstancesFunc. func (mock *RepositoryMock) Instances() []string { if mock.InstancesFunc == nil { @@ -480,21 +529,23 @@ func (mock *RepositoryMock) RunningCalls() []struct { } // Start calls StartFunc. -func (mock *RepositoryMock) Start(name string, runningConfig RunningConfig) error { +func (mock *RepositoryMock) Start(ctx context.Context, name string, runningConfig RunningConfig) error { if mock.StartFunc == nil { panic("RepositoryMock.StartFunc: method is nil but Repository.Start was just called") } callInfo := struct { + Ctx context.Context Name string RunningConfig RunningConfig }{ + Ctx: ctx, Name: name, RunningConfig: runningConfig, } mock.lockStart.Lock() mock.calls.Start = append(mock.calls.Start, callInfo) mock.lockStart.Unlock() - return mock.StartFunc(name, runningConfig) + return mock.StartFunc(ctx, name, runningConfig) } // StartCalls gets all the calls that were made to Start. @@ -502,10 +553,12 @@ func (mock *RepositoryMock) Start(name string, runningConfig RunningConfig) erro // // len(mockedRepository.StartCalls()) func (mock *RepositoryMock) StartCalls() []struct { + Ctx context.Context Name string RunningConfig RunningConfig } { var calls []struct { + Ctx context.Context Name string RunningConfig RunningConfig } diff --git a/pkg/daemonize/daemonize.go b/pkg/daemonize/daemonize.go index a887d38..f4042e0 100644 --- a/pkg/daemonize/daemonize.go +++ b/pkg/daemonize/daemonize.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BSD-3-Clause -// Copyright (C) 2020-2025, RtBrick, Inc. +// Copyright (C) 2020-2026, RtBrick, Inc. package daemonize import ( @@ -12,23 +12,31 @@ import ( type Daemon func() error // Daemonize the function. +// +// It blocks until either start returns, in which case the returned signal is +// NormalTerminationSignal together with start's error, or a termination +// signal arrives, in which case that signal is returned with a nil error and +// start is left running so the caller can shut it down gracefully. func Daemonize(start Daemon) (os.Signal, error) { // Handle common process-killing signals so we can gracefully shut down: sigc := make(chan os.Signal, 1) signal.Notify(sigc, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) - var err error + defer signal.Stop(sigc) + + // The error travels over its own buffered channel rather than a shared + // variable: on a signal, start keeps running and returns later, which + // would otherwise race with the caller reading the error. + errc := make(chan error, 1) go func() { - // Start a server; `err` will be returned to the caller: - err = start() - // Signal completion: - sigc <- NormalTerminationSignal{} - signal.Stop(sigc) + errc <- start() }() - // Wait for a termination signal (normal or otherwise): - sig := <-sigc - - return sig, err + select { + case sig := <-sigc: + return sig, nil + case err := <-errc: + return NormalTerminationSignal{}, err + } } // NormalTerminationSignal signal implementation for normal program termination. diff --git a/pkg/daemonize/daemonize_test.go b/pkg/daemonize/daemonize_test.go index 099cb97..3094d6d 100644 --- a/pkg/daemonize/daemonize_test.go +++ b/pkg/daemonize/daemonize_test.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BSD-3-Clause -// Copyright (C) 2020-2025, RtBrick, Inc. +// Copyright (C) 2020-2026, RtBrick, Inc. package daemonize import ( diff --git a/pkg/server/apidocs.go b/pkg/server/apidocs.go new file mode 100644 index 0000000..fa5a2cb --- /dev/null +++ b/pkg/server/apidocs.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. +package server + +import ( + "net/http" + + "github.com/rtbrick/bngblaster-controller/docs" +) + +// registerAPIDocsRoutes exposes the embedded OpenAPI/Swagger definition and +// a Swagger UI viewer for it at /docs/. It is independent of the web UI +// (registered regardless of WithUI) since it documents the REST API itself. +func (s *Server) registerAPIDocsRoutes() { + s.router.Path("/docs").Methods(http.MethodGet).Handler(http.RedirectHandler("/docs/", http.StatusMovedPermanently)) + s.router.Path("/docs/").Methods(http.MethodGet).Handler(withCSP(apiDocsCSP, s.apiDocsAsset("index.html", "text/html; charset=utf-8"))) + s.router.Path("/docs/swagger.yaml").Methods(http.MethodGet).Handler(s.apiDocsAsset("swagger.yaml", "application/yaml")) +} + +func (s *Server) apiDocsAsset(name, ct string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + content, err := docs.Assets.ReadFile(name) + if err != nil { + JSONError(w, "api docs not available", http.StatusInternalServerError) + return + } + w.Header().Set(contentType, ct) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + } +} + +// withCSP replaces the default Content-Security-Policy for a handler that +// serves an actual page. +func withCSP(policy string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", policy) + next.ServeHTTP(w, r) + }) +} diff --git a/pkg/server/cache.go b/pkg/server/cache.go new file mode 100644 index 0000000..e8d5d07 --- /dev/null +++ b/pkg/server/cache.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. +package server + +import ( + "sync" + "time" +) + +const ( + // summaryCacheTTL is how long a summary response fetched from the + // bngblaster control socket is reused for. The stream/session list views + // issue one HTTP request per visible range while the user scrolls; + // without a short-lived cache each of those would open a new unix socket + // connection and re-run the (potentially large) summary command. + summaryCacheTTL = 2 * time.Second + + // maxSummaryCacheEntries bounds a cache, which is keyed per instance + // *and* per distinct filter combination (see streamFilters). It is reset + // wholesale once this many entries accumulate rather than tracked with + // per-entry eviction, since it only exists to make short-lived UI polling + // cheap, not to be a long-lived store. + maxSummaryCacheEntries = 64 +) + +type cacheEntry[T any] struct { + fetchedAt time.Time + value T + err error +} + +// inflight is a single in-progress fetch that later arrivals for the same key +// wait on instead of issuing a duplicate control-socket round-trip. +type inflight[T any] struct { + done chan struct{} + value T + err error +} + +// summaryCache memoizes control-socket responses per instance (and filter +// combination) for a short period. It exists purely to make server-side +// pagination cheap; it is not a source of truth and always expires quickly. +// +// Concurrent misses for the same key are coalesced into a single fetch: the +// UI polls every 2s with a 2s TTL, so without coalescing every poll would be +// a miss by construction and N open browser tabs would mean N socket +// round-trips for identical data. +type summaryCache[T any] struct { + mu sync.Mutex + entries map[string]cacheEntry[T] + calls map[string]*inflight[T] +} + +func newSummaryCache[T any]() *summaryCache[T] { + return &summaryCache[T]{ + entries: map[string]cacheEntry[T]{}, + calls: map[string]*inflight[T]{}, + } +} + +func (c *summaryCache[T]) get(key string, fetch func() (T, error)) (T, error) { + c.mu.Lock() + if entry, ok := c.entries[key]; ok && time.Since(entry.fetchedAt) < summaryCacheTTL { + c.mu.Unlock() + return entry.value, entry.err + } + // Somebody else is already fetching exactly this: wait for their result + // rather than opening a second socket for the same data. + if call, ok := c.calls[key]; ok { + c.mu.Unlock() + <-call.done + return call.value, call.err + } + call := &inflight[T]{done: make(chan struct{})} + c.calls[key] = call + c.mu.Unlock() + + call.value, call.err = fetch() + + c.mu.Lock() + if len(c.entries) >= maxSummaryCacheEntries { + // Keyed per instance *and* filter combination, so an interactive user + // trying out several filters can otherwise grow this unboundedly over + // a long session. This isn't a source of truth, so wiping it wholesale + // is safe - anyone still polling just refetches on their next request. + c.entries = map[string]cacheEntry[T]{} + } + c.entries[key] = cacheEntry[T]{fetchedAt: time.Now(), value: call.value, err: call.err} + delete(c.calls, key) + c.mu.Unlock() + + close(call.done) + return call.value, call.err +} + +// invalidate drops every entry belonging to one instance. Called whenever an +// instance's lifecycle changes (start/stop/kill/delete) so the UI does not +// keep being served up to summaryCacheTTL of stale rows from the previous +// run, and so a deleted instance leaves nothing behind. +func (c *summaryCache[T]) invalidate(instance string) { + c.mu.Lock() + defer c.mu.Unlock() + for key := range c.entries { + if key == instance || (len(key) > len(instance) && key[:len(instance)] == instance && key[len(instance)] == '|') { + delete(c.entries, key) + } + } +} diff --git a/pkg/server/files.go b/pkg/server/files.go new file mode 100644 index 0000000..5d66019 --- /dev/null +++ b/pkg/server/files.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. +package server + +import ( + "encoding/json" + "net/http" + "path/filepath" + "strconv" + "time" + + "github.com/gorilla/mux" + "github.com/rs/zerolog/log" +) + +// disableWriteDeadline lifts the server-wide WriteTimeout for the current +// request. That timeout protects the JSON endpoints against stuck clients, +// but it is measured from the end of the request headers, so it would also +// abort any pcap or log download (and any upload response) that simply +// takes longer than the timeout over a slow link. +func disableWriteDeadline(w http.ResponseWriter) { + // An error only means the writer does not support deadlines (e.g. in + // tests); the server-wide timeout then keeps applying, which is safe. + _ = http.NewResponseController(w).SetWriteDeadline(time.Time{}) +} + +// files lists the downloadable files present in an instance's config +// folder, used by the web UI's "Download" view. +func (s *Server) files() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + instanceVariable := mux.Vars(r)[instanceNameParameter] + instance := cleanPathVariable(instanceVariable) + if !s.repository.Exists(instance) { + JSONNotFound(w, r) + return + } + + files, err := s.repository.Files(instance) + if err != nil { + JSONError(w, "not able to list files", http.StatusInternalServerError) + return + } + + w.Header().Set(contentType, applicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(files) + } +} + +// fileDownload serves a single file out of an instance's config folder. +// Unlike the fixed-name route registered for the well-known result files, +// this accepts any file name (e.g. user-uploaded files) since it only ever +// downloads names the files() endpoint itself just listed - path traversal +// is prevented by only ever taking the base component of the requested name. +// +// The folder holds arbitrary user-uploaded content, so the response is +// forced to a download: without "Content-Disposition: attachment" plus +// "X-Content-Type-Options: nosniff", an uploaded .html or .svg file would be +// served inline and execute script in the controller's own origin - a stored +// cross-site scripting vector against every other user of this UI. +func (s *Server) fileDownload() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + remoteAddr := clientIP(r) + instanceVariable := mux.Vars(r)[instanceNameParameter] + instance := cleanPathVariable(instanceVariable) + if !s.repository.Exists(instance) { + JSONNotFound(w, r) + return + } + // Base's own degenerate outputs ("", ".", "..", "/") are rejected + // outright since joining any of them would land outside the + // instance folder - see isUnsafeFileName. + file := filepath.Base(mux.Vars(r)["file_name"]) + if isUnsafeFileName(file) { + http.Error(w, "invalid filename", http.StatusBadRequest) + return + } + log.Info().Str("remote_addr", remoteAddr).Str("instance", instance).Str("file", file).Msg("file downloaded") + w.Header().Set("Content-Disposition", "attachment; filename="+strconv.Quote(file)) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set(contentType, "application/octet-stream") + disableWriteDeadline(w) + http.ServeFile(w, r, filepath.Join(s.repository.ConfigFolder(), instance, file)) + } +} diff --git a/pkg/server/files_test.go b/pkg/server/files_test.go new file mode 100644 index 0000000..f4973a2 --- /dev/null +++ b/pkg/server/files_test.go @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. +package server + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/require" + + "github.com/rtbrick/bngblaster-controller/pkg/controller" +) + +func TestServer_files(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + FilesFunc: func(name string) ([]controller.InstanceFile, error) { + return []controller.InstanceFile{{Name: "run_report.json", Size: 12}}, nil + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances/test/_files") + require.Equal(t, http.StatusOK, recorder.Code) + + var files []controller.InstanceFile + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &files)) + require.Equal(t, []controller.InstanceFile{{Name: "run_report.json", Size: 12}}, files) +} + +func TestServer_fileDownload_isForcedToADownload(t *testing.T) { + folder := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(folder, "test"), 0o700)) + // The instance folder holds arbitrary uploaded content. Served inline, + // this would execute script in the controller's own origin. + payload := "" + require.NoError(t, os.WriteFile(filepath.Join(folder, "test", "evil.html"), []byte(payload), 0o600)) + + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return folder }, + ExistsFunc: func(name string) bool { return true }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances/test/_files/evil.html") + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, payload, recorder.Body.String()) + require.Equal(t, `attachment; filename="evil.html"`, recorder.Header().Get("Content-Disposition")) + require.Equal(t, "nosniff", recorder.Header().Get("X-Content-Type-Options")) + require.Equal(t, "application/octet-stream", recorder.Header().Get("Content-Type"), + "the browser must never be told this is renderable HTML") +} + +// TestServer_fileDownload_rejectsUnsafeFilename exercises fileDownload's own +// isUnsafeFileName guard directly via mux.SetURLVars, bypassing the router. +// A real request can't reach the handler with these file_name values in the +// first place - gorilla/mux cleans "." and ".." path segments and redirects +// before routing - but the guard is defense in depth for exactly that +// scenario, so it must be verified independently of the router. +func TestServer_fileDownload_rejectsUnsafeFilename(t *testing.T) { + folder := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(folder, "test"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(folder, "outside.txt"), []byte("secret"), 0o600)) + + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return folder }, + ExistsFunc: func(name string) bool { return true }, + } + handler := NewServer(repository) + + for _, filename := range []string{"..", ".", "/"} { + t.Run(filename, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/instances/test/_files/x", nil) + req = mux.SetURLVars(req, map[string]string{instanceNameParameter: "test", "file_name": filename}) + recorder := httptest.NewRecorder() + handler.fileDownload()(recorder, req) + require.Equal(t, http.StatusBadRequest, recorder.Code) + }) + } +} + +func TestServer_fileDownload_missingInstance(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return false }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances/test/_files/whatever.json") + require.Equal(t, http.StatusNotFound, recorder.Code) +} + +// uploadRequest builds a multipart upload carrying the given (possibly +// hostile) filename. +func uploadRequest(t *testing.T, instance, filename, content string) *http.Request { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", filename) + require.NoError(t, err) + _, err = part.Write([]byte(content)) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + request := httptest.NewRequest(http.MethodPost, "/api/v1/instances/"+instance+"/_upload", &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + return request +} + +func TestServer_uploadFile_cannotEscapeInstanceFolder(t *testing.T) { + root := t.TempDir() + folder := filepath.Join(root, "configs") + require.NoError(t, os.MkdirAll(filepath.Join(folder, "test"), 0o700)) + + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return folder }, + AllowUploadFunc: func() bool { return true }, + ExistsFunc: func(name string) bool { return true }, + } + handler := NewServer(repository) + + // net/http strips the directory components itself, and the handler takes + // the base name again on top of that. This pins the resulting guarantee: + // whatever a client puts in the multipart filename, the upload lands + // inside the instance folder and nowhere else. + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, uploadRequest(t, "test", "../../pwned.txt", "payload")) + require.Equal(t, http.StatusOK, recorder.Code) + + _, err := os.Stat(filepath.Join(root, "pwned.txt")) + require.True(t, os.IsNotExist(err), "upload must not escape the instance folder") + _, err = os.Stat(filepath.Join(folder, "pwned.txt")) + require.True(t, os.IsNotExist(err), "upload must not escape the instance folder") + + // It lands under its base name inside the instance folder instead. + written, err := os.ReadFile(filepath.Join(folder, "test", "pwned.txt")) + require.NoError(t, err) + require.Equal(t, "payload", string(written)) +} + +func TestServer_uploadFile_rejectsUnsafeFilename(t *testing.T) { + folder := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(folder, "test"), 0o700)) + + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return folder }, + AllowUploadFunc: func() bool { return true }, + ExistsFunc: func(name string) bool { return true }, + } + handler := NewServer(repository) + + for _, filename := range []string{"..", ".", "/"} { + t.Run(filename, func(t *testing.T) { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, uploadRequest(t, "test", filename, "payload")) + require.Equal(t, http.StatusBadRequest, recorder.Code) + }) + } + + entries, err := os.ReadDir(filepath.Join(folder, "test")) + require.NoError(t, err) + require.Empty(t, entries, "no file should have been written for an unsafe filename") +} + +func TestServer_uploadFile_rejectsRunFiles(t *testing.T) { + folder := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(folder, "test"), 0o700)) + + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return folder }, + AllowUploadFunc: func() bool { return true }, + ExistsFunc: func(_ string) bool { return true }, + } + handler := NewServer(repository) + + // A planted run.pid would make _kill signal an arbitrary process as root. + for _, filename := range []string{"run.pid", "run.sock", "run.json"} { + t.Run(filename, func(t *testing.T) { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, uploadRequest(t, "test", filename, "1")) + require.Equal(t, http.StatusBadRequest, recorder.Code) + }) + } + + entries, err := os.ReadDir(filepath.Join(folder, "test")) + require.NoError(t, err) + require.Empty(t, entries, "no run file should have been written") +} + +func TestServer_uploadFile_storesPlainNameUnchanged(t *testing.T) { + folder := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(folder, "test"), 0o700)) + + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return folder }, + AllowUploadFunc: func() bool { return true }, + ExistsFunc: func(name string) bool { return true }, + } + handler := NewServer(repository) + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, uploadRequest(t, "test", "streams.json", `{"streams":[]}`)) + require.Equal(t, http.StatusOK, recorder.Code) + + written, err := os.ReadFile(filepath.Join(folder, "test", "streams.json")) + require.NoError(t, err) + require.Equal(t, `{"streams":[]}`, string(written)) +} diff --git a/pkg/server/hardening.go b/pkg/server/hardening.go new file mode 100644 index 0000000..0ff47b1 --- /dev/null +++ b/pkg/server/hardening.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. +package server + +import ( + "errors" + "net" + "net/http" + "strings" + + "github.com/rs/zerolog/log" +) + +// Request body limits. The controller runs as root without authentication, +// so no endpoint may let a single request exhaust memory or disk. +const ( + // maxConfigSize bounds an instance configuration (PUT). Generous, since + // a config may carry thousands of inline stream definitions. + maxConfigSize = 32 << 20 + // maxRequestSize bounds the small JSON bodies of _start and _command. + maxRequestSize = 1 << 20 + // maxUploadSize bounds a single file upload, plus some headroom for the + // multipart framing around it. + maxUploadSize = 4000<<20 + 1<<20 +) + +// Content-Security-Policy values. defaultCSP is sent with every response +// and makes any document the API serves (a log, a report, config.json +// opened in a browser tab) inert: no scripts, no subresources, no framing. +// Those files can contain text an API caller controls, and would otherwise +// run with the controller's origin if a browser ever rendered them as HTML. +// The web UI and the API docs replace it with the policy they need. +const ( + defaultCSP = "default-src 'none'; frame-ancestors 'none'; sandbox" + // uiCSP allows only the embedded assets. Inline styles remain allowed + // since the markup uses style attributes; inline scripts do not. + uiCSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; " + + "connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'" + // apiDocsCSP allows the Swagger UI bundle loaded from unpkg and its + // inline bootstrap script. + apiDocsCSP = "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com; " + + "style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https:; " + + "connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" +) + +// securityHeadersMiddleware sets the headers every response carries. +// Handlers that serve an actual page overwrite Content-Security-Policy. +func securityHeadersMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Content-Security-Policy", defaultCSP) + next.ServeHTTP(w, r) + }) +} + +// crossOriginMiddleware rejects state-changing requests (anything but GET, +// HEAD and OPTIONS) that a browser sends on behalf of another site. +// +// Without authentication this is what stops a web page opened by anyone on +// the lab network from starting, killing or uploading into instances +// through that person's browser: a plain HTML form can POST cross-site +// without any CORS preflight. Browsers mark such requests via +// Sec-Fetch-Site or Origin; curl and other non-browser clients send +// neither header and are unaffected. +func crossOriginMiddleware() func(http.Handler) http.Handler { + protection := http.NewCrossOriginProtection() + protection.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Warn().Str("remote_addr", clientIP(r)).Str("method", r.Method).Str("origin", r.Header.Get("Origin")). + Str("sec_fetch_site", r.Header.Get("Sec-Fetch-Site")).Msg("cross-origin request rejected: " + r.RequestURI) + JSONError(w, "cross-origin request rejected", http.StatusForbidden) + })) + return protection.Handler +} + +// hostAllowlistMiddleware rejects requests whose Host header names a host +// that is not in allowed. It defends against DNS rebinding: a malicious +// site can re-point its own domain at the controller's address, after which +// the browser treats the controller as same-origin with that site and lets +// its scripts read every response. The Host header still carries the +// attacker's domain though, which is what is checked here. +// +// IP literals and localhost are always accepted, since a rebinding attack +// can only ever present a domain name the attacker controls. An empty +// allowed list disables the check. +func hostAllowlistMiddleware(allowed []string) func(http.Handler) http.Handler { + hosts := make(map[string]struct{}, len(allowed)) + for _, host := range allowed { + if host = normalizeHost(host); host != "" { + hosts[host] = struct{}{} + } + } + return func(next http.Handler) http.Handler { + if len(hosts) == 0 { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = normalizeHost(host) + if _, ok := hosts[host]; ok || host == "localhost" || net.ParseIP(host) != nil { + next.ServeHTTP(w, r) + return + } + log.Warn().Str("remote_addr", clientIP(r)).Str("host", r.Host).Msg("request rejected: host not allowed") + JSONError(w, "host not allowed", http.StatusForbidden) + }) + } +} + +// normalizeHost lower-cases a host name and strips IPv6 brackets and a +// trailing root dot, so "Lab01.example.com." and "lab01.example.com" match. +func normalizeHost(host string) string { + host = strings.TrimSpace(strings.ToLower(host)) + host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") + return strings.TrimSuffix(host, ".") +} + +// isBodyTooLarge reports whether err stems from a body that exceeded its +// http.MaxBytesReader limit. +func isBodyTooLarge(err error) bool { + var maxBytesError *http.MaxBytesError + return errors.As(err, &maxBytesError) +} diff --git a/pkg/server/hardening_test.go b/pkg/server/hardening_test.go new file mode 100644 index 0000000..9160fbe --- /dev/null +++ b/pkg/server/hardening_test.go @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/rtbrick/bngblaster-controller/pkg/controller" +) + +func TestServer_rejectsCrossOriginStateChanges(t *testing.T) { + var stopped int + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + StopFunc: func(_ string) { stopped++ }, + } + handler := NewServer(repository) + + tests := []struct { + name string + headers map[string]string + want int + }{ + // A form on another site, submitted through the victim's browser. + {"cross-site", map[string]string{"Sec-Fetch-Site": "cross-site"}, http.StatusForbidden}, + {"foreign origin", map[string]string{"Origin": "http://evil.example"}, http.StatusForbidden}, + {"same origin", map[string]string{"Sec-Fetch-Site": "same-origin"}, http.StatusAccepted}, + {"matching origin", map[string]string{"Origin": "http://example.com"}, http.StatusAccepted}, + // curl, scripts and CI send neither header. + {"non-browser client", nil, http.StatusAccepted}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stopped = 0 + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/instances/test/_stop", nil) + for k, v := range tt.headers { + request.Header.Set(k, v) + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + require.Equal(t, tt.want, recorder.Code) + require.Equal(t, tt.want == http.StatusAccepted, stopped == 1) + }) + } + + // Reads stay possible cross-site; the browser's same-origin policy + // already keeps another site from seeing the response. + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/instances", nil) + request.Header.Set("Sec-Fetch-Site", "cross-site") + repository.InstancesFunc = func() []string { return nil } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + require.Equal(t, http.StatusOK, recorder.Code) +} + +func TestServer_hostAllowlist(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + InstancesFunc: func() []string { return nil }, + } + handler := NewServer(repository, WithAllowedHosts([]string{"Lab01.example.com."})) + + for host, want := range map[string]int{ + "lab01.example.com:8001": http.StatusOK, + "LAB01.EXAMPLE.COM": http.StatusOK, + "10.0.0.5:8001": http.StatusOK, + "[2001:db8::1]:8001": http.StatusOK, + "localhost:8001": http.StatusOK, + // A rebinding attacker's own domain, pointed at the controller. + "rebind.evil.example:8001": http.StatusForbidden, + } { + t.Run(host, func(t *testing.T) { + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/instances", nil) + request.Host = host + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + require.Equal(t, want, recorder.Code) + }) + } + + // Without an allowlist every host is accepted, as before. + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/instances", nil) + request.Host = "rebind.evil.example" + recorder := httptest.NewRecorder() + NewServer(repository).ServeHTTP(recorder, request) + require.Equal(t, http.StatusOK, recorder.Code) +} + +func TestServer_securityHeaders(t *testing.T) { + handler := uiServer("1.2.3") + + api := doGet(t, handler, "/api/v1/schema") + require.Equal(t, "DENY", api.Header().Get("X-Frame-Options")) + require.Equal(t, "nosniff", api.Header().Get("X-Content-Type-Options")) + require.Equal(t, "no-referrer", api.Header().Get("Referrer-Policy")) + require.Equal(t, defaultCSP, api.Header().Get("Content-Security-Policy"), + "files an API caller controls must never render as an active page") + + ui := doGet(t, handler, "/") + require.Equal(t, uiCSP, ui.Header().Get("Content-Security-Policy")) + require.Equal(t, "DENY", ui.Header().Get("X-Frame-Options")) + + require.Equal(t, apiDocsCSP, doGet(t, handler, "/docs/").Header().Get("Content-Security-Policy")) +} + +func TestServer_create_rejectsOversizedConfig(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(_ string) bool { return false }, + CreateFunc: func(_ string, _ []byte) error { return nil }, + } + body := strings.NewReader("{\"x\":\"" + strings.Repeat("a", maxConfigSize) + "\"}") + request := httptest.NewRequestWithContext(t.Context(), http.MethodPut, "/api/v1/instances/test", body) + recorder := httptest.NewRecorder() + NewServer(repository).ServeHTTP(recorder, request) + require.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code) + require.Empty(t, repository.CreateCalls()) +} + +func TestServer_start_rejectsInvalidStreamConfig(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + StartFunc: func(_ context.Context, _ string, _ controller.RunningConfig) error { + return controller.ErrInvalidStreamConfig + }, + } + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/instances/test/_start", + strings.NewReader(`{"stream_config": "/etc/shadow"}`)) + recorder := httptest.NewRecorder() + NewServer(repository).ServeHTTP(recorder, request) + require.Equal(t, http.StatusBadRequest, recorder.Code) +} + +func TestServer_uploadFile_rejectsWhenDiskIsTooSmall(t *testing.T) { + folder := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(folder, "test"), 0o700)) + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return folder }, + AllowUploadFunc: func() bool { return true }, + ExistsFunc: func(_ string) bool { return true }, + } + + request := uploadRequest(t, "test", "big.bin", "payload") + // No file system has an exabyte free. + request.ContentLength = 1 << 60 + recorder := httptest.NewRecorder() + NewServer(repository).ServeHTTP(recorder, request) + require.Equal(t, http.StatusInsufficientStorage, recorder.Code) + + entries, err := os.ReadDir(filepath.Join(folder, "test")) + require.NoError(t, err) + require.Empty(t, entries) +} + +func TestServer_uploadFile_leavesNoTemporaryFile(t *testing.T) { + folder := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(folder, "test"), 0o700)) + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return folder }, + AllowUploadFunc: func() bool { return true }, + ExistsFunc: func(_ string) bool { return true }, + } + + recorder := httptest.NewRecorder() + NewServer(repository).ServeHTTP(recorder, uploadRequest(t, "test", "streams.json", "{}")) + require.Equal(t, http.StatusOK, recorder.Code) + + entries, err := os.ReadDir(filepath.Join(folder, "test")) + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, "streams.json", entries[0].Name()) +} diff --git a/pkg/server/logs.go b/pkg/server/logs.go new file mode 100644 index 0000000..e12209c --- /dev/null +++ b/pkg/server/logs.go @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. +package server + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "path" + "syscall" + + "github.com/gorilla/mux" + + "github.com/rtbrick/bngblaster-controller/pkg/controller" +) + +const ( + defaultLogReadLimit = 64 * 1024 + maxLogReadLimit = 1 << 20 +) + +// logsResponse is returned by the log tail endpoint. NextOffset should be +// passed back as the "offset" query parameter on the following poll so the +// viewer only ever receives newly appended log lines. +// +// Generation identifies the log file itself (its inode), not its contents. +// Starting an instance deletes and recreates run.log, so an offset carried +// over from a previous run points into a file that no longer exists: if the +// new log has already grown past that offset, a plain size comparison cannot +// detect the rotation and everything written before it is silently skipped. +// A client must therefore reset its offset to 0 whenever Generation changes. +type logsResponse struct { + Generation uint64 `json:"generation"` + Offset int64 `json:"offset"` + NextOffset int64 `json:"next_offset"` + EOF bool `json:"eof"` + Lines []string `json:"lines"` +} + +// generationPrefixLen is how many bytes from the start of a log file are +// hashed into its generation. Enough to cover the first log line, which +// carries a timestamp and therefore differs between runs. +const generationPrefixLen = 256 + +// logGeneration returns an identifier that changes whenever the log file a +// client is reading is replaced by a different one. +// +// File metadata cannot answer this. Starting an instance deletes run.log and +// immediately recreates it, which on ext4 reuses the just-freed inode - and +// with it the recorded birth time - so neither identifies the new file as +// distinct. What reliably differs is the content: the first log line carries +// a timestamp from the run that wrote it. Hashing the file's leading bytes +// together with its inode therefore answers the question actually being +// asked, which is "is this still the file whose offset I am holding?". +func logGeneration(f *os.File, info os.FileInfo) uint64 { + prefix := make([]byte, generationPrefixLen) + n, err := f.ReadAt(prefix, 0) + if err != nil && err != io.EOF { + n = 0 + } + prefix = prefix[:n] + + // FNV-1a over the inode followed by the content prefix. + const ( + offset64 = 14695981039346656037 + prime64 = 1099511628211 + ) + hash := uint64(offset64) + mix := func(b byte) { + hash ^= uint64(b) + hash *= prime64 + } + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + for shift := 0; shift < 64; shift += 8 { + mix(byte(stat.Ino >> shift)) //nolint:gosec // truncation to the low byte is the point + } + } + for _, b := range prefix { + mix(b) + } + return hash +} + +// logs is a STUB handler for the instance log viewer. +// +// bngblaster does not currently expose a socket command to stream log +// messages, so this implementation tails the run.log file that the +// bngblaster process writes to when started with logging enabled +// (see RunningConfig.Logging). The UI polls this endpoint with the +// "offset" it last received, which keeps the request cheap regardless of +// how large the log file grows. +// +// Once bngblaster gains a native "log" (or similar) socket command capable +// of streaming structured log messages, this handler should be replaced +// with one that forwards to repository.Command the same way s.streams() +// does, without requiring any change to the frontend's polling contract +// (offset/next_offset/eof/lines). +func (s *Server) logs() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + instanceVariable := mux.Vars(r)[instanceNameParameter] + instance := cleanPathVariable(instanceVariable) + if !s.repository.Exists(instance) { + JSONNotFound(w, r) + return + } + + offset := int64(parseNonNegativeIntQuery(r, "offset", 0)) + limit := parseNonNegativeIntQuery(r, "limit", defaultLogReadLimit) + if limit <= 0 || limit > maxLogReadLimit { + limit = defaultLogReadLimit + } + + file := path.Join(s.repository.ConfigFolder(), instance, controller.RunLogFilename) + resp, err := tailLogFile(file, offset, limit) + if err != nil { + // No log file yet (e.g. instance never started with logging + // enabled) is not an error from the UI's perspective. + if os.IsNotExist(err) { + // No log file yet: generation 0 tells the client to reset, + // so a stale offset from a previous run cannot survive. + w.Header().Set(contentType, applicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(logsResponse{Offset: 0, NextOffset: 0, EOF: true, Lines: []string{}}) + return + } + JSONError(w, "not able to read log", http.StatusInternalServerError) + return + } + + w.Header().Set(contentType, applicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + } +} + +func tailLogFile(file string, offset int64, limit int) (logsResponse, error) { + f, err := os.Open(file) + if err != nil { + return logsResponse{}, err + } + defer func() { + _ = f.Close() + }() + + info, err := f.Stat() + if err != nil { + return logsResponse{}, err + } + size := info.Size() + generation := logGeneration(f, info) + if offset > size { + // File was truncated/rotated since the last poll; restart from 0. + offset = 0 + } + + toRead := size - offset + if toRead > int64(limit) { + toRead = int64(limit) + } + if toRead < 0 { + toRead = 0 + } + + if _, err := f.Seek(offset, io.SeekStart); err != nil { + return logsResponse{}, err + } + + buf := make([]byte, toRead) + n, err := io.ReadFull(f, buf) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return logsResponse{}, err + } + buf = buf[:n] + nextOffset := offset + int64(n) + + // Only emit complete lines; keep any trailing partial line for the next + // poll by not advancing nextOffset past the last newline. + lastNewline := bytes.LastIndexByte(buf, '\n') + var complete []byte + if lastNewline != -1 { + complete = buf[:lastNewline+1] + nextOffset = offset + int64(lastNewline+1) + } + + var lines []string + scanner := bufio.NewScanner(bytes.NewReader(complete)) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if lines == nil { + lines = []string{} + } + + return logsResponse{ + Generation: generation, + Offset: offset, + NextOffset: nextOffset, + EOF: nextOffset >= size, + Lines: lines, + }, nil +} diff --git a/pkg/server/logs_test.go b/pkg/server/logs_test.go new file mode 100644 index 0000000..226bd72 --- /dev/null +++ b/pkg/server/logs_test.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. +package server + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func writeLogFile(t *testing.T, path, content string) { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +} + +func TestTailLogFile_readsCompleteLinesOnly(t *testing.T) { + file := filepath.Join(t.TempDir(), "run.log") + writeLogFile(t, file, "first\nsecond\npartial") + + resp, err := tailLogFile(file, 0, defaultLogReadLimit) + require.NoError(t, err) + + // "partial" has no terminating newline yet, so it must be held back for + // the next poll rather than emitted as a truncated line. + require.Equal(t, []string{"first", "second"}, resp.Lines) + require.Equal(t, int64(0), resp.Offset) + require.Equal(t, int64(len("first\nsecond\n")), resp.NextOffset) + require.False(t, resp.EOF) + require.NotZero(t, resp.Generation) +} + +func TestTailLogFile_resumesFromOffset(t *testing.T) { + file := filepath.Join(t.TempDir(), "run.log") + writeLogFile(t, file, "first\nsecond\n") + + first, err := tailLogFile(file, 0, defaultLogReadLimit) + require.NoError(t, err) + require.Equal(t, []string{"first", "second"}, first.Lines) + require.True(t, first.EOF) + + writeLogFile(t, file, "first\nsecond\nthird\n") + + second, err := tailLogFile(file, first.NextOffset, defaultLogReadLimit) + require.NoError(t, err) + require.Equal(t, []string{"third"}, second.Lines) + require.True(t, second.EOF) +} + +func TestTailLogFile_rewindsWhenFileShrank(t *testing.T) { + file := filepath.Join(t.TempDir(), "run.log") + writeLogFile(t, file, "aaaa\nbbbb\ncccc\n") + long, err := tailLogFile(file, 0, defaultLogReadLimit) + require.NoError(t, err) + + // A restart recreates run.log; a shorter replacement is detectable from + // the size alone and must restart from the beginning. + writeLogFile(t, file, "new\n") + resp, err := tailLogFile(file, long.NextOffset, defaultLogReadLimit) + require.NoError(t, err) + require.Equal(t, int64(0), resp.Offset) + require.Equal(t, []string{"new"}, resp.Lines) +} + +func TestTailLogFile_generationChangesWhenFileIsReplaced(t *testing.T) { + file := filepath.Join(t.TempDir(), "run.log") + writeLogFile(t, file, "one\ntwo\n") + before, err := tailLogFile(file, 0, defaultLogReadLimit) + require.NoError(t, err) + + // The case a size comparison cannot catch: the file is replaced and the + // replacement is already longer than the offset carried over from the + // previous run. Only the generation reveals that the offset is stale. + require.NoError(t, os.Remove(file)) + writeLogFile(t, file, "alpha\nbravo\ncharlie\ndelta\n") + + after, err := tailLogFile(file, before.NextOffset, defaultLogReadLimit) + require.NoError(t, err) + require.NotEqual(t, before.Generation, after.Generation, + "a recreated log file must report a different generation") +} + +func TestTailLogFile_respectsLimit(t *testing.T) { + file := filepath.Join(t.TempDir(), "run.log") + writeLogFile(t, file, "aaaa\nbbbb\ncccc\n") + + // Only "aaaa\n" fits whole inside a 7 byte budget. + resp, err := tailLogFile(file, 0, 7) + require.NoError(t, err) + require.Equal(t, []string{"aaaa"}, resp.Lines) + require.Equal(t, int64(5), resp.NextOffset) + require.False(t, resp.EOF) +} + +func TestTailLogFile_missingFile(t *testing.T) { + _, err := tailLogFile(filepath.Join(t.TempDir(), "absent.log"), 0, defaultLogReadLimit) + require.True(t, os.IsNotExist(err)) +} diff --git a/pkg/server/options.go b/pkg/server/options.go new file mode 100644 index 0000000..59f4492 --- /dev/null +++ b/pkg/server/options.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. +package server + +import "net/http" + +// DefaultSchemaPath is the default location of the bngblaster configuration +// JSON schema, used to drive the "New Instance" config editor in the web UI. +const DefaultSchemaPath = "/usr/share/bngblaster/bngblaster-config.json" + +// AuthMiddleware is the function signature used to plug in authentication. +// It wraps a http.Handler and is invoked for every request routed through +// the server, before the UI and API handlers. +type AuthMiddleware func(http.Handler) http.Handler + +// noopAuthMiddleware is the default AuthMiddleware. It performs no +// authentication and simply forwards the request. Replace it with +// WithAuthMiddleware once a login/authentication mechanism is required. +func noopAuthMiddleware(next http.Handler) http.Handler { + return next +} + +// Option configures optional behavior of the Server. +type Option func(*Server) + +// WithUI enables or disables serving the embedded web UI on "/". The web +// UI is experimental and disabled by default. +func WithUI(enabled bool) Option { + return func(s *Server) { + s.enableUI = enabled + } +} + +// WithInterfacesAPI enables or disables the "/api/v1/interfaces" endpoint +// which reports the network interfaces available on the host. Disabled by +// default. +func WithInterfacesAPI(enabled bool) Option { + return func(s *Server) { + s.enableInterfaces = enabled + } +} + +// WithSchemaPath sets the file system location of the bngblaster +// configuration JSON schema served via "/api/v1/schema". Defaults to +// DefaultSchemaPath. +func WithSchemaPath(path string) Option { + return func(s *Server) { + s.schemaPath = path + } +} + +// WithAllowedHosts restricts the host names clients may use to address the +// server (the Host header), as a defense against DNS rebinding. IP literals +// and localhost are always accepted. An empty list, the default, accepts +// any host. +func WithAllowedHosts(hosts []string) Option { + return func(s *Server) { + s.allowedHosts = hosts + } +} + +// WithAuthMiddleware installs the given middleware in front of every route +// (UI and API alike). This is the extension point intended for adding +// login/session/token based authentication later without restructuring the +// routing table. +func WithAuthMiddleware(mw AuthMiddleware) Option { + return func(s *Server) { + if mw != nil { + s.authMiddleware = mw + } + } +} diff --git a/pkg/server/overview.go b/pkg/server/overview.go new file mode 100644 index 0000000..94b6734 --- /dev/null +++ b/pkg/server/overview.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. +package server + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "github.com/rtbrick/bngblaster-controller/pkg/controller" +) + +// overviewCommands are the control socket commands aggregated by the +// instance overview endpoint. The key of each entry in the response is the +// command name, which is also the key bngblaster wraps its payload in. +var overviewCommands = []string{ + "session-counters", + "network-interfaces", + "access-interfaces", + "a10nsp-interfaces", + "test-info", +} + +// overview aggregates every control socket command the instance detail view +// polls into a single cached response: GET .../_overview +// +// The Session Overview tab previously issued one request per command every +// two seconds, and the header duration badge a fifth, so a single open +// browser tab meant five uncached unix socket round-trips every two seconds +// and N tabs meant 5*N. Serving them from one endpoint behind the shared +// summary cache collapses that to one round-trip per command per cache +// period regardless of how many viewers are watching. +// +// A command that fails individually (unsupported by this bngblaster build, +// or simply not applicable) yields a null value for its key rather than +// failing the whole response - the UI hides the corresponding section. +func (s *Server) overview() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + instanceVariable := mux.Vars(r)[instanceNameParameter] + instance := cleanPathVariable(instanceVariable) + if !s.repository.Exists(instance) { + JSONNotFound(w, r) + return + } + + result, err := s.overviewCache.get(instance, func() (map[string]json.RawMessage, error) { + out := map[string]json.RawMessage{} + var firstErr error + for _, command := range overviewCommands { + payload, err := s.repository.Command(instance, controller.SocketCommand{Command: command}) + if err != nil { + // ErrBlasterNotRunning applies to every command equally, so + // remember it and report it once the loop is done; anything + // else is treated as "this command is unavailable". + if firstErr == nil { + firstErr = err + } + continue + } + var envelope map[string]json.RawMessage + if err := json.Unmarshal(payload, &envelope); err != nil { + continue + } + if value, ok := envelope[command]; ok { + out[command] = value + } + } + if len(out) == 0 && firstErr != nil { + return nil, firstErr + } + return out, nil + }) + if err == controller.ErrBlasterNotRunning { + JSONError(w, "instance is not running", http.StatusPreconditionFailed) + return + } + if err != nil { + JSONError(w, "not able to fetch instance overview", http.StatusInternalServerError) + return + } + + // Always emit every key so the client can tell "not reported" from + // "not requested" without knowing the command list itself. + response := map[string]json.RawMessage{} + for _, command := range overviewCommands { + response[command] = result[command] + } + + w.Header().Set(contentType, applicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response) + } +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 74a3296..599d1b5 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -1,12 +1,14 @@ // SPDX-License-Identifier: BSD-3-Clause -// Copyright (C) 2020-2025, RtBrick, Inc. +// Copyright (C) 2020-2026, RtBrick, Inc. package server import ( "bytes" "encoding/json" + "errors" "fmt" "io" + "mime/multipart" "net" "net/http" "os" @@ -14,13 +16,15 @@ import ( "path" "path/filepath" "strings" + "sync" + "syscall" "github.com/gorilla/mux" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/rtbrick/bngblaster-controller/pkg/controller" - - "github.com/prometheus/client_golang/prometheus/promhttp" ) const ( @@ -37,12 +41,76 @@ func cleanPathVariable(instanceVariable string) string { return instance } +// isUnsafeFileName reports whether name - already reduced to its base +// component via filepath.Base - could still escape the directory it is +// joined into. Base only strips leading directory components, so a +// filename that is itself "", ".", ".." or "/" (Base's results for those +// inputs) would otherwise resolve to the parent or instance directory +// itself instead of a file inside it. +func isUnsafeFileName(name string) bool { + return name == "" || name == "." || name == ".." || name == string(filepath.Separator) +} + +// clientIP returns the request's source IP, stripping the port from +// RemoteAddr. This is the direct TCP peer address rather than a +// client-supplied header (e.g. X-Forwarded-For), which cannot be trusted +// unless this server sits behind a specifically configured proxy. +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +// auditLog starts an info log event for a state-changing request. Without +// authentication the client address is the only record of who did what. +func auditLog(r *http.Request, instance string) *zerolog.Event { + return log.Info().Str("remote_addr", clientIP(r)).Str("instance", instance) //nolint:zerologlint // callers dispatch it +} + +// availableDiskSpace returns the bytes available to the controller on the +// file system holding dir. ok is false if that cannot be determined, in +// which case the caller should not reject anything based on it. +func availableDiskSpace(dir string) (uint64, bool) { + var stat syscall.Statfs_t + if err := syscall.Statfs(dir, &stat); err != nil { + return 0, false + } + return stat.Bavail * uint64(stat.Bsize), true //nolint:gosec // block size is never negative +} + // Server implementation for the rest api. type Server struct { Version string router *mux.Router prom *controller.Prom repository controller.Repository + + // enableUI toggles serving the embedded web UI on "/". + enableUI bool + // enableInterfaces toggles the "/api/v1/interfaces" endpoint. + enableInterfaces bool + // schemaPath is the file system location of the bngblaster config schema. + schemaPath string + // allowedHosts restricts the Host header of incoming requests (see + // hostAllowlistMiddleware). Empty accepts any host. + allowedHosts []string + // authMiddleware is invoked for every request. It is a no-op unless + // WithAuthMiddleware is used, and is the extension point for plugging + // in authentication/login later. + authMiddleware AuthMiddleware + + streamCache *summaryCache[[]controller.StreamSummaryStream] + sessionCache *summaryCache[[]controller.SessionSummarySession] + overviewCache *summaryCache[map[string]json.RawMessage] + + // assetVersion is appended to every embedded UI asset URL as a cache + // busting query parameter, so a browser can cache them aggressively yet + // never run a stale app.js against a newer controller. It is resolved + // lazily because Version is assigned after NewServer returns. + assetVersion string + assetVersionOnce sync.Once } // InterfaceInfo holds the information about a network interface. @@ -62,12 +130,22 @@ type VersionInfo struct { } // NewServer is a constructor function for Server. -func NewServer(repository controller.Repository) *Server { +func NewServer(repository controller.Repository, opts ...Option) *Server { r := &Server{ - Version: "dev", - router: mux.NewRouter(), - prom: controller.NewProm(repository), - repository: repository, + Version: "dev", + router: mux.NewRouter(), + prom: controller.NewProm(repository), + repository: repository, + enableUI: false, + enableInterfaces: false, + schemaPath: DefaultSchemaPath, + authMiddleware: noopAuthMiddleware, + streamCache: newSummaryCache[[]controller.StreamSummaryStream](), + sessionCache: newSummaryCache[[]controller.SessionSummarySession](), + overviewCache: newSummaryCache[map[string]json.RawMessage](), + } + for _, opt := range opts { + opt(r) } r.routes() return r @@ -81,7 +159,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Do stuff here. - log.Info().Str("method", r.Method).Msg(r.RequestURI) + log.Info().Str("method", r.Method).Str("remote_addr", clientIP(r)).Msg(r.RequestURI) // Call the next handler, which can be another middleware in the chain, or the final handler. next.ServeHTTP(w, r) }) @@ -90,6 +168,16 @@ func loggingMiddleware(next http.Handler) http.Handler { func (s *Server) routes() { const instanceURL = "/api/v1/instances/{instance_name}" s.router.Use(loggingMiddleware) + // Hardening that does not depend on authentication; see hardening.go. + // Security headers come first so that rejections carry them too. + s.router.Use(securityHeadersMiddleware) + s.router.Use(hostAllowlistMiddleware(s.allowedHosts)) + s.router.Use(crossOriginMiddleware()) + // authMiddleware is a no-op unless WithAuthMiddleware(...) was supplied. + // It sits in front of both the UI and the API so a future login system + // can be introduced here without touching individual handlers. + s.router.Use(mux.MiddlewareFunc(s.authMiddleware)) + // Expose the registered metrics via HTTP. s.router.Path("/metrics").Methods(http.MethodGet).Handler(promhttp.HandlerFor( s.prom.Registry, @@ -98,8 +186,18 @@ func (s *Server) routes() { }, )) s.router.Path("/api/v1/version").Methods(http.MethodGet).Handler(s.version()) - s.router.Path("/api/v1/interfaces").Methods(http.MethodGet).Handler(s.interfaces()) + s.router.Path("/api/v1/schema").Methods(http.MethodGet).Handler(s.schema()) + s.registerAPIDocsRoutes() + if s.enableInterfaces { + s.router.Path("/api/v1/interfaces").Methods(http.MethodGet).Handler(s.interfaces()) + } s.router.Path("/api/v1/instances").Methods(http.MethodGet).Handler(s.instances()) + s.router.Path(instanceURL + "/_overview").Methods(http.MethodGet).Handler(s.overview()) + s.router.Path(instanceURL + "/_streams").Methods(http.MethodGet).Handler(s.streams()) + s.router.Path(instanceURL + "/_sessions").Methods(http.MethodGet).Handler(s.sessions()) + s.router.Path(instanceURL + "/_logs").Methods(http.MethodGet).Handler(s.logs()) + s.router.Path(instanceURL + "/_files").Methods(http.MethodGet).Handler(s.files()) + s.router.Path(instanceURL + "/_files/{file_name}").Methods(http.MethodGet).Handler(s.fileDownload()) s.router. Path( fmt.Sprintf("%s/{file_name:%s|%s|%s|%s|%s|%s|%s}", @@ -121,6 +219,10 @@ func (s *Server) routes() { s.router.Path(instanceURL + "/_kill").Methods(http.MethodPost).Handler(s.kill()) s.router.Path(instanceURL + "/_command").Methods(http.MethodPost).Handler(s.command()) s.router.Path(instanceURL + "/_upload").Methods(http.MethodPost).Handler(s.uploadFile()) + + if s.enableUI { + s.registerUIRoutes() + } } func (s *Server) fileServing(directory string) http.HandlerFunc { @@ -128,19 +230,56 @@ func (s *Server) fileServing(directory string) http.HandlerFunc { instanceVariable := mux.Vars(r)[instanceNameParameter] instance := cleanPathVariable(instanceVariable) file := mux.Vars(r)["file_name"] + disableWriteDeadline(w) http.ServeFile(w, r, path.Join(directory, instance, file)) } } +// instanceDetail is one entry of the detailed instance listing. +type instanceDetail struct { + Name string `json:"name"` + Status string `json:"status"` +} + +// instances lists the configured instances. By default this is the plain +// array of names it has always been; "?detail=true" instead returns each +// name together with its status. +// +// The dashboard refreshes its table every few seconds and needs the status +// of every instance, which previously meant one request for the list plus +// one per instance on every refresh. The detailed form collapses that into +// a single request. func (s *Server) instances() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { instances := s.repository.Instances() w.Header().Set(contentType, applicationJSON) w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(instances) + if r.URL.Query().Get("detail") != "true" { + _ = json.NewEncoder(w).Encode(instances) + return + } + details := make([]instanceDetail, 0, len(instances)) + for _, name := range instances { + status := "stopped" + if s.repository.Running(name) { + status = "started" + } + details = append(details, instanceDetail{Name: name, Status: status}) + } + _ = json.NewEncoder(w).Encode(details) } } +// invalidateInstanceCaches drops every cached summary belonging to an +// instance. Called whenever its lifecycle changes so a start/stop/kill/delete +// is reflected immediately instead of after the cache period, and so a +// deleted instance leaves nothing cached behind it. +func (s *Server) invalidateInstanceCaches(instance string) { + s.streamCache.invalidate(instance) + s.sessionCache.invalidate(instance) + s.overviewCache.invalidate(instance) +} + // getReadableInterfaceFlags converts interface flags to a readable format. func getReadableInterfaceFlags(flags net.Flags) []string { var readableFlags []string @@ -196,7 +335,6 @@ func (s *Server) interfaces() http.HandlerFunc { // getVersion returns server and bngblaster version informations. func getVersion(s *Server) VersionInfo { - versionInfo := VersionInfo{ Version: s.Version, BlasterVersion: "NA", @@ -235,11 +373,30 @@ func (s *Server) version() http.HandlerFunc { } } +// schema serves the bngblaster configuration JSON schema used by the web UI +// to render and validate the "New Instance" config editor. +func (s *Server) schema() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + content, err := os.ReadFile(s.schemaPath) + if err != nil { + JSONError(w, "schema not available", http.StatusNotFound) + return + } + w.Header().Set(contentType, applicationJSON) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + } +} + func (s *Server) create() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { instanceVariable := mux.Vars(r)[instanceNameParameter] instance := cleanPathVariable(instanceVariable) - content, err := io.ReadAll(r.Body) + content, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxConfigSize)) + if isBodyTooLarge(err) { + http.Error(w, "config too large", http.StatusRequestEntityTooLarge) + return + } if err != nil || len(content) == 0 { http.Error(w, "body not readable", http.StatusBadRequest) return @@ -257,6 +414,7 @@ func (s *Server) create() http.HandlerFunc { http.Error(w, "not able to create instance", http.StatusInternalServerError) return } + auditLog(r, instance).Msg("instance configured") w.WriteHeader(status) } } @@ -288,6 +446,7 @@ func (s *Server) delete() http.HandlerFunc { instanceVariable := mux.Vars(r)[instanceNameParameter] instance := cleanPathVariable(instanceVariable) status := http.StatusNoContent + s.invalidateInstanceCaches(instance) err := s.repository.Delete(instance) if err == controller.ErrBlasterRunning { JSONError(w, errInstanceIsRunning, http.StatusPreconditionFailed) @@ -297,6 +456,7 @@ func (s *Server) delete() http.HandlerFunc { JSONError(w, "not able to delete instance", http.StatusInternalServerError) return } + auditLog(r, instance).Msg("instance deleted") w.WriteHeader(status) } } @@ -306,7 +466,11 @@ func (s *Server) start() http.HandlerFunc { instanceVariable := mux.Vars(r)[instanceNameParameter] instance := cleanPathVariable(instanceVariable) var runningConfig controller.RunningConfig - err := json.NewDecoder(r.Body).Decode(&runningConfig) + err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxRequestSize)).Decode(&runningConfig) + if isBodyTooLarge(err) { + JSONError(w, "request too large", http.StatusRequestEntityTooLarge) + return + } if err != nil { JSONError(w, err.Error(), http.StatusBadRequest) return @@ -314,7 +478,8 @@ func (s *Server) start() http.HandlerFunc { status := http.StatusNoContent - err = s.repository.Start(instance, runningConfig) + s.invalidateInstanceCaches(instance) + err = s.repository.Start(r.Context(), instance, runningConfig) if err == controller.ErrBlasterNotExists { JSONNotFound(w, r) return @@ -323,10 +488,17 @@ func (s *Server) start() http.HandlerFunc { JSONError(w, errInstanceIsRunning, http.StatusPreconditionFailed) return } + if err == controller.ErrInvalidStreamConfig { + auditLog(r, instance).Str("stream_config", runningConfig.StreamConfig).Msg("start rejected: invalid stream config") + JSONError(w, err.Error(), http.StatusBadRequest) + return + } if err != nil { - JSONError(w, "not able to start", http.StatusInternalServerError) + auditLog(r, instance).Err(err).Msg("instance start failed") + JSONError(w, err.Error(), http.StatusInternalServerError) return } + auditLog(r, instance).Msg("instance started") w.WriteHeader(status) } } @@ -337,6 +509,8 @@ func (s *Server) stop() http.HandlerFunc { instance := cleanPathVariable(instanceVariable) status := http.StatusAccepted s.repository.Stop(instance) + s.invalidateInstanceCaches(instance) + auditLog(r, instance).Msg("instance stop requested") w.WriteHeader(status) } } @@ -347,6 +521,8 @@ func (s *Server) kill() http.HandlerFunc { instance := cleanPathVariable(instanceVariable) status := http.StatusAccepted s.repository.Kill(instance) + s.invalidateInstanceCaches(instance) + auditLog(r, instance).Msg("instance kill requested") w.WriteHeader(status) } } @@ -359,11 +535,19 @@ func (s *Server) command() http.HandlerFunc { instanceVariable := mux.Vars(r)[instanceNameParameter] instance := cleanPathVariable(instanceVariable) var command controller.SocketCommand - err := json.NewDecoder(r.Body).Decode(&command) + err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxRequestSize)).Decode(&command) + if isBodyTooLarge(err) { + JSONError(w, "request too large", http.StatusRequestEntityTooLarge) + return + } if err != nil { JSONError(w, err.Error(), http.StatusBadRequest) return } + // Debug only: the web UI issues read-only commands on every poll, + // and the request itself is already logged by loggingMiddleware. + log.Debug().Str("remote_addr", clientIP(r)).Str("instance", instance).Str("command", command.Command). + Msg("instance command") status := http.StatusOK @@ -398,6 +582,7 @@ func (s *Server) command() http.HandlerFunc { func (s *Server) uploadFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + remoteAddr := clientIP(r) instanceVariable := mux.Vars(r)[instanceNameParameter] instance := cleanPathVariable(instanceVariable) if !s.repository.Exists(instance) { @@ -406,42 +591,119 @@ func (s *Server) uploadFile() http.HandlerFunc { } if !s.repository.AllowUpload() { + log.Warn().Str("remote_addr", remoteAddr).Str("instance", instance).Msg("upload forbidden") http.Error(w, "forbidden", http.StatusForbidden) return } - err := r.ParseMultipartForm(4000 << 20) // Max upload size set to 4000 MB - if err != nil { - http.Error(w, "error parsing multipart form", http.StatusRequestEntityTooLarge) - return + // Reading a multi-GB body counts against the write timeout too. + disableWriteDeadline(w) + instanceFolder := filepath.Join(s.repository.ConfigFolder(), instance) + if r.ContentLength > 0 { + if available, ok := availableDiskSpace(instanceFolder); ok && uint64(r.ContentLength) > available { + log.Warn().Str("remote_addr", remoteAddr).Str("instance", instance).Int64("size", r.ContentLength). + Uint64("available", available).Msg("upload rejected: insufficient disk space") + http.Error(w, "insufficient disk space", http.StatusInsufficientStorage) + return + } } - file, handler, err := r.FormFile("file") + // The multipart body is streamed straight into the instance folder + // rather than parsed with ParseMultipartForm, which would spool it + // through os.TempDir (often a small tmpfs) first and does not bound + // the total size at all. MaxBytesReader does. + r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) + part, err := uploadFilePart(r) + if isBodyTooLarge(err) { + http.Error(w, "file too large", http.StatusRequestEntityTooLarge) + return + } if err != nil { http.Error(w, "error retrieving file", http.StatusBadRequest) return } - defer file.Close() - - filePath := filepath.Join(s.repository.ConfigFolder(), instance, handler.Filename) - - destFile, err := os.Create(filePath) - if err != nil { - http.Error(w, "failed to create file", http.StatusInternalServerError) + defer part.Close() + + // Only ever the base name. Part.FileName already strips directory + // components (RFC 7578 requires it), so this is belt and braces - + // but the guarantee that an upload cannot escape the instance folder + // is worth stating at the point where the path is built rather than + // relying on a library's behavior. Base's own degenerate outputs + // ("", ".", "..", "/") are rejected outright since joining any of + // them would land outside the instance folder. + name := filepath.Base(part.FileName()) + if isUnsafeFileName(name) { + log.Warn().Str("remote_addr", remoteAddr).Str("instance", instance).Str("file", part.FileName()). + Msg("upload rejected: invalid filename") + http.Error(w, "invalid filename", http.StatusBadRequest) + return + } + if controller.IsRunFile(name) { + log.Warn().Str("remote_addr", remoteAddr).Str("instance", instance).Str("file", name). + Msg("upload rejected: reserved run file") + http.Error(w, "reserved filename", http.StatusBadRequest) return } - defer destFile.Close() - _, err = io.Copy(destFile, file) - if err != nil { + err = saveUpload(instanceFolder, name, part) + switch { + case isBodyTooLarge(err): + http.Error(w, "file too large", http.StatusRequestEntityTooLarge) + return + case errors.Is(err, syscall.ENOSPC): + log.Warn().Str("remote_addr", remoteAddr).Str("instance", instance).Str("file", name). + Msg("upload failed: disk full") + http.Error(w, "insufficient disk space", http.StatusInsufficientStorage) + return + case err != nil: http.Error(w, "failed to save file", http.StatusInternalServerError) return } + log.Info().Str("remote_addr", remoteAddr).Str("instance", instance).Str("file", name).Msg("file uploaded") + w.WriteHeader(http.StatusOK) } } +// uploadFilePart returns the multipart part carrying the uploaded file (the +// "file" field), skipping any other form fields before it. +func uploadFilePart(r *http.Request) (*multipart.Part, error) { + reader, err := r.MultipartReader() + if err != nil { + return nil, err + } + for { + part, err := reader.NextPart() + if err != nil { + return nil, err + } + if part.FormName() == "file" && part.FileName() != "" { + return part, nil + } + } +} + +// saveUpload stores src as folder/name. It writes to a temporary file next +// to the target and renames it into place, so an aborted, oversized or +// disk-full upload never leaves a truncated file behind or clobbers the +// previous version. +func saveUpload(folder, name string, src io.Reader) error { + tmpFile, err := os.CreateTemp(folder, ".upload-*") + if err != nil { + return err + } + defer os.Remove(tmpFile.Name()) // no-op once renamed + _, err = io.Copy(tmpFile, src) + if closeErr := tmpFile.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + return os.Rename(tmpFile.Name(), filepath.Join(folder, name)) +} + type message struct { Message interface{} `json:"message"` } diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index b53a5c6..93bff08 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -1,8 +1,9 @@ // SPDX-License-Identifier: BSD-3-Clause -// Copyright (C) 2020-2025, RtBrick, Inc. +// Copyright (C) 2020-2026, RtBrick, Inc. package server import ( + "context" "fmt" "net/http" "net/http/httptest" @@ -246,7 +247,7 @@ func TestServer_start(t *testing.T) { name: "error", resultStart: fmt.Errorf("other error"), body: &controller.RunningConfig{}, - wantBody: "not able to start", + wantBody: "other error", want: http.StatusInternalServerError, }, } @@ -256,7 +257,7 @@ func TestServer_start(t *testing.T) { ConfigFolderFunc: func() string { return configFolder }, - StartFunc: func(name string, config controller.RunningConfig) error { + StartFunc: func(_ context.Context, name string, config controller.RunningConfig) error { return tt.resultStart }, } diff --git a/pkg/server/sessions.go b/pkg/server/sessions.go new file mode 100644 index 0000000..4d1a1fe --- /dev/null +++ b/pkg/server/sessions.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. +package server + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/gorilla/mux" + + "github.com/rtbrick/bngblaster-controller/pkg/controller" +) + +const ( + defaultSessionPageSize = 50 + maxSessionPageSize = 500 +) + +// sessionFilters mirrors the filter arguments accepted by the bngblaster +// "session-summary" control socket command. +type sessionFilters struct { + SessionID *int + SessionGroupID *int + SessionIDMin *int + SessionIDMax *int +} + +func parseSessionFilters(r *http.Request) sessionFilters { + f := sessionFilters{} + f.SessionID = parseOptionalIntQuery(r, "session-id") + f.SessionGroupID = parseOptionalIntQuery(r, "session-group-id") + f.SessionIDMin = parseOptionalIntQuery(r, "session-id-min") + f.SessionIDMax = parseOptionalIntQuery(r, "session-id-max") + return f +} + +// cacheKey is a stable string encoding of the filter set, used as (part of) +// the session-summary cache key. +func (f sessionFilters) cacheKey() string { + key := "" + if f.SessionID != nil { + key += fmt.Sprintf("|session-id=%d", *f.SessionID) + } + if f.SessionGroupID != nil { + key += fmt.Sprintf("|session-group-id=%d", *f.SessionGroupID) + } + if f.SessionIDMin != nil { + key += fmt.Sprintf("|session-id-min=%d", *f.SessionIDMin) + } + if f.SessionIDMax != nil { + key += fmt.Sprintf("|session-id-max=%d", *f.SessionIDMax) + } + return key +} + +// arguments builds the "arguments" object sent alongside the +// "session-summary" socket command. +func (f sessionFilters) arguments() map[string]interface{} { + args := map[string]interface{}{} + if f.SessionID != nil { + args["session-id"] = *f.SessionID + } + if f.SessionGroupID != nil { + args["session-group-id"] = *f.SessionGroupID + } + if f.SessionIDMin != nil { + args["session-id-min"] = *f.SessionIDMin + } + if f.SessionIDMax != nil { + args["session-id-max"] = *f.SessionIDMax + } + return args +} + +// sessionsResponse is the paginated view of session-summary returned to the +// UI, mirroring streamsResponse's "floating range" contract for the +// virtual-scrolling session table. +type sessionsResponse struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Items []controller.SessionSummarySession `json:"items"` +} + +// sessions implements the "floating range" pagination endpoint backing the +// virtual-scrolling session table: GET .../_sessions?offset=&limit= +func (s *Server) sessions() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + instanceVariable := mux.Vars(r)[instanceNameParameter] + instance := cleanPathVariable(instanceVariable) + if !s.repository.Exists(instance) { + JSONNotFound(w, r) + return + } + + offset := parseNonNegativeIntQuery(r, "offset", 0) + limit := parseNonNegativeIntQuery(r, "limit", defaultSessionPageSize) + if limit <= 0 { + limit = defaultSessionPageSize + } + if limit > maxSessionPageSize { + limit = maxSessionPageSize + } + + filters := parseSessionFilters(r) + // See streams(): "window=1" distinguishes a range the virtual scroller + // derived from its scroll position from one the user typed in. + windowed := r.URL.Query().Get("window") == "1" && filters.SessionIDMin != nil && filters.SessionIDMax != nil + cacheKey := instance + filters.cacheKey() + + sessionsData, err := s.sessionCache.get(cacheKey, func() ([]controller.SessionSummarySession, error) { + result, err := s.repository.Command(instance, controller.SocketCommand{ + Command: "session-summary", + Arguments: filters.arguments(), + }) + if err != nil { + return nil, err + } + var parsed controller.SessionSummaryResponse + if err := json.Unmarshal(result, &parsed); err != nil { + return nil, err + } + return parsed.Sessions, nil + }) + if err == controller.ErrBlasterNotRunning { + JSONError(w, "instance is not running", http.StatusPreconditionFailed) + return + } + if err != nil { + JSONError(w, "not able to fetch session summary", http.StatusInternalServerError) + return + } + + var resp sessionsResponse + if windowed { + // Same reasoning as streams(): a session-id range generated by the + // UI's virtual-scroll window is already exactly the slice about to + // be rendered, and Offset is the absolute row index it starts at. + resp = sessionsResponse{ + Total: len(sessionsData), + Offset: *filters.SessionIDMin - 1, + Limit: limit, + Items: sessionsData, + } + } else { + // Everything else - including a user-entered session-id range - is + // plain offset/limit pagination over the filtered result. + total := len(sessionsData) + start := offset + if start > total { + start = total + } + end := start + limit + if end > total { + end = total + } + resp = sessionsResponse{ + Total: total, + Offset: start, + Limit: limit, + Items: sessionsData[start:end], + } + } + + w.Header().Set(contentType, applicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + } +} diff --git a/pkg/server/streams.go b/pkg/server/streams.go new file mode 100644 index 0000000..d7c6692 --- /dev/null +++ b/pkg/server/streams.go @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/gorilla/mux" + + "github.com/rtbrick/bngblaster-controller/pkg/controller" +) + +const ( + defaultStreamPageSize = 50 + maxStreamPageSize = 500 +) + +// streamFilters mirrors the filter arguments accepted by the bngblaster +// "stream-summary" control socket command, allowing the stream table to +// narrow down the (potentially large) stream list server-side instead of +// downloading everything and filtering in the browser. +type streamFilters struct { + SessionID *int + SessionGroupID *int + FlowID *int + FlowIDMin *int + FlowIDMax *int + Name string + Interface string + Direction string + // State is one of "verified", "bidirectional-verified", "pending" or "" + // (any), matching the mutually exclusive verified-only / + // bidirectional-verified-only / pending-only socket command arguments. + State string +} + +func parseStreamFilters(r *http.Request) streamFilters { + q := r.URL.Query() + f := streamFilters{ + Name: q.Get("name"), + Interface: q.Get("interface"), + Direction: q.Get("direction"), + State: q.Get("state"), + } + f.SessionID = parseOptionalIntQuery(r, "session-id") + f.SessionGroupID = parseOptionalIntQuery(r, "session-group-id") + f.FlowID = parseOptionalIntQuery(r, "flow-id") + f.FlowIDMin = parseOptionalIntQuery(r, "flow-id-min") + f.FlowIDMax = parseOptionalIntQuery(r, "flow-id-max") + return f +} + +func parseOptionalIntQuery(r *http.Request, name string) *int { + raw := r.URL.Query().Get(name) + if raw == "" { + return nil + } + v, err := strconv.Atoi(raw) + if err != nil { + return nil + } + return &v +} + +// cacheKey is a stable string encoding of the filter set, used as (part of) +// the stream-summary cache key. +func (f streamFilters) cacheKey() string { + key := "" + if f.SessionID != nil { + key += fmt.Sprintf("|session-id=%d", *f.SessionID) + } + if f.SessionGroupID != nil { + key += fmt.Sprintf("|session-group-id=%d", *f.SessionGroupID) + } + if f.FlowID != nil { + key += fmt.Sprintf("|flow-id=%d", *f.FlowID) + } + if f.FlowIDMin != nil { + key += fmt.Sprintf("|flow-id-min=%d", *f.FlowIDMin) + } + if f.FlowIDMax != nil { + key += fmt.Sprintf("|flow-id-max=%d", *f.FlowIDMax) + } + if f.Name != "" { + key += "|name=" + f.Name + } + if f.Interface != "" { + key += "|interface=" + f.Interface + } + if f.Direction != "" { + key += "|direction=" + f.Direction + } + if f.State != "" { + key += "|state=" + f.State + } + return key +} + +// arguments builds the "arguments" object sent alongside the +// "stream-summary" socket command. +func (f streamFilters) arguments() map[string]interface{} { + args := map[string]interface{}{} + if f.SessionID != nil { + args["session-id"] = *f.SessionID + } + if f.SessionGroupID != nil { + args["session-group-id"] = *f.SessionGroupID + } + if f.FlowID != nil { + args["flow-id"] = *f.FlowID + } + if f.FlowIDMin != nil { + args["flow-id-min"] = *f.FlowIDMin + } + if f.FlowIDMax != nil { + args["flow-id-max"] = *f.FlowIDMax + } + if f.Name != "" { + args["name"] = f.Name + } + if f.Interface != "" { + args["interface"] = f.Interface + } + if f.Direction != "" { + args["direction"] = f.Direction + } + switch f.State { + case "verified": + args["verified-only"] = true + case "bidirectional-verified": + args["bidirectional-verified-only"] = true + case "pending": + args["pending-only"] = true + } + return args +} + +// streamsResponse is the paginated view of stream-summary returned to the UI. +// This is the "floating range" contract used by the virtual scrolling stream +// table: the client only ever asks for the slice of rows currently in (or +// near) the viewport instead of downloading the entire stream list. +type streamsResponse struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Items []controller.StreamSummaryStream `json:"items"` +} + +// streams implements the "floating range" pagination endpoint backing the +// virtual-scrolling stream table: GET .../_streams?offset=&limit= +func (s *Server) streams() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + instanceVariable := mux.Vars(r)[instanceNameParameter] + instance := cleanPathVariable(instanceVariable) + if !s.repository.Exists(instance) { + JSONNotFound(w, r) + return + } + + offset := parseNonNegativeIntQuery(r, "offset", 0) + limit := parseNonNegativeIntQuery(r, "limit", defaultStreamPageSize) + if limit <= 0 { + limit = defaultStreamPageSize + } + if limit > maxStreamPageSize { + limit = maxStreamPageSize + } + + filters := parseStreamFilters(r) + // "window=1" marks a flow-id range the UI's virtual scroller derived + // from its scroll position rather than one the user typed into the + // filter panel. The two need different pagination semantics (see + // below), and only the client knows which is which. + windowed := r.URL.Query().Get("window") == "1" && filters.FlowIDMin != nil && filters.FlowIDMax != nil + cacheKey := instance + filters.cacheKey() + + streamsData, err := s.streamCache.get(cacheKey, func() ([]controller.StreamSummaryStream, error) { + result, err := s.repository.Command(instance, controller.SocketCommand{ + Command: "stream-summary", + Arguments: filters.arguments(), + }) + if err != nil { + return nil, err + } + var parsed controller.StreamSummaryResponse + if err := json.Unmarshal(result, &parsed); err != nil { + return nil, err + } + return parsed.Streams, nil + }) + if err == controller.ErrBlasterNotRunning { + JSONError(w, "instance is not running", http.StatusPreconditionFailed) + return + } + if err != nil { + JSONError(w, "not able to fetch stream summary", http.StatusInternalServerError) + return + } + + var resp streamsResponse + if windowed { + // The flow-id range was generated by the UI's virtual-scroll + // window, not typed by a user: it asked bngblaster for exactly the + // slice of streams it is about to render, so that slice is returned + // as-is. Offset is the absolute row index the slice starts at, + // which for a sequentially assigned flow-id chain is FlowIDMin-1. + resp = streamsResponse{ + Total: len(streamsData), + Offset: *filters.FlowIDMin - 1, + Limit: limit, + Items: streamsData, + } + } else { + // Everything else - including a user-entered flow-id range - is + // plain offset/limit pagination over the filtered result. Offset + // is a row index into that result and Total is its full length, so + // the client can size a scrollbar for the filtered list correctly. + total := len(streamsData) + start := offset + if start > total { + start = total + } + end := start + limit + if end > total { + end = total + } + resp = streamsResponse{ + Total: total, + Offset: start, + Limit: limit, + Items: streamsData[start:end], + } + } + + w.Header().Set(contentType, applicationJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) + } +} + +func parseNonNegativeIntQuery(r *http.Request, name string, def int) int { + raw := r.URL.Query().Get(name) + if raw == "" { + return def + } + v, err := strconv.Atoi(raw) + if err != nil || v < 0 { + return def + } + return v +} diff --git a/pkg/server/summary_test.go b/pkg/server/summary_test.go new file mode 100644 index 0000000..2717dc2 --- /dev/null +++ b/pkg/server/summary_test.go @@ -0,0 +1,391 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/rtbrick/bngblaster-controller/pkg/controller" +) + +// streamSummaryJSON builds a stream-summary socket response holding streams +// with the given flow ids. +func streamSummaryJSON(flowIDs ...int) []byte { + streams := make([]controller.StreamSummaryStream, 0, len(flowIDs)) + for _, id := range flowIDs { + streams = append(streams, controller.StreamSummaryStream{FlowId: id, Name: fmt.Sprintf("stream-%d", id)}) + } + payload, err := json.Marshal(controller.StreamSummaryResponse{Code: 200, Streams: streams}) + if err != nil { + panic(err) + } + return payload +} + +func sessionSummaryJSON(sessionIDs ...int) []byte { + sessions := make([]controller.SessionSummarySession, 0, len(sessionIDs)) + for _, id := range sessionIDs { + sessions = append(sessions, controller.SessionSummarySession{SessionId: id}) + } + payload, err := json.Marshal(controller.SessionSummaryResponse{Code: 200, Sessions: sessions}) + if err != nil { + panic(err) + } + return payload +} + +func rangeOf(from, to int) []int { + ids := make([]int, 0, to-from+1) + for id := from; id <= to; id++ { + ids = append(ids, id) + } + return ids +} + +func doGet(t *testing.T, handler http.Handler, target string) *httptest.ResponseRecorder { + t.Helper() + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, target, nil)) + return recorder +} + +func TestServer_streams_windowedRangeKeepsAbsoluteOffset(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + // bngblaster has already narrowed the result to the requested range. + return streamSummaryJSON(rangeOf(101, 110)...), nil + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, + "/api/v1/instances/test/_streams?offset=100&limit=10&flow-id-min=101&flow-id-max=110&window=1") + require.Equal(t, http.StatusOK, recorder.Code) + + var resp streamsResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + // A scroller window is returned as-is, offset being the absolute row it + // starts at, so the client can position it without re-deriving anything. + require.Equal(t, 100, resp.Offset) + require.Len(t, resp.Items, 10) + require.Equal(t, 101, resp.Items[0].FlowId) +} + +func TestServer_streams_userRangeIsPaginatedAsAFlatList(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + return streamSummaryJSON(rangeOf(100001, 100010)...), nil + }, + } + handler := NewServer(repository) + + // The same range typed into the filter panel: without "window=1" this is + // an ordinary filtered list. Reporting offset 100000 with a total of 10 + // (as it once did) made the client render a multi-million pixel spacer + // above ten rows and claim to be showing "rows 100001-100010 of 10". + recorder := doGet(t, handler, + "/api/v1/instances/test/_streams?offset=0&limit=5&flow-id-min=100001&flow-id-max=100010") + require.Equal(t, http.StatusOK, recorder.Code) + + var resp streamsResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Offset, "offset must be a row index, not a flow id") + require.Equal(t, 10, resp.Total, "total must be the full filtered count") + require.Len(t, resp.Items, 5) + require.Equal(t, 100001, resp.Items[0].FlowId) + + // ... and the second page continues from where the first left off. + recorder = doGet(t, handler, + "/api/v1/instances/test/_streams?offset=5&limit=5&flow-id-min=100001&flow-id-max=100010") + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, 5, resp.Offset) + require.Equal(t, 10, resp.Total) + require.Equal(t, 100006, resp.Items[0].FlowId) +} + +func TestServer_streams_onlyMinBoundIsNotAWindow(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + return streamSummaryJSON(rangeOf(50, 59)...), nil + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances/test/_streams?offset=2&limit=3&flow-id-min=50&window=1") + require.Equal(t, http.StatusOK, recorder.Code) + + var resp streamsResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + // window=1 needs both bounds to mean anything; a half-open range falls + // back to plain pagination rather than being returned unsliced. + require.Equal(t, 2, resp.Offset) + require.Equal(t, 10, resp.Total) + require.Len(t, resp.Items, 3) + require.Equal(t, 52, resp.Items[0].FlowId) +} + +func TestServer_streams_offsetBeyondEnd(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + return streamSummaryJSON(1, 2, 3), nil + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances/test/_streams?offset=99&limit=10") + require.Equal(t, http.StatusOK, recorder.Code) + + var resp streamsResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, 3, resp.Total) + require.Equal(t, 3, resp.Offset) + require.Empty(t, resp.Items) +} + +func TestServer_streams_notRunning(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + return nil, controller.ErrBlasterNotRunning + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances/test/_streams") + require.Equal(t, http.StatusPreconditionFailed, recorder.Code) +} + +func TestServer_sessions_windowedRangeKeepsAbsoluteOffset(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + return sessionSummaryJSON(rangeOf(21, 30)...), nil + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, + "/api/v1/instances/test/_sessions?offset=20&limit=10&session-id-min=21&session-id-max=30&window=1") + require.Equal(t, http.StatusOK, recorder.Code) + + var resp sessionsResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, 20, resp.Offset) + require.Len(t, resp.Items, 10) +} + +func TestServer_sessions_userRangeIsPaginatedAsAFlatList(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + return sessionSummaryJSON(rangeOf(9001, 9010)...), nil + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, + "/api/v1/instances/test/_sessions?offset=0&limit=4&session-id-min=9001&session-id-max=9010") + require.Equal(t, http.StatusOK, recorder.Code) + + var resp sessionsResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Offset) + require.Equal(t, 10, resp.Total) + require.Len(t, resp.Items, 4) +} + +func TestSummaryCache_coalescesConcurrentMisses(t *testing.T) { + cache := newSummaryCache[int]() + var fetches int32 + release := make(chan struct{}) + + var wg sync.WaitGroup + results := make([]int, 20) + for i := range results { + wg.Add(1) + go func(idx int) { + defer wg.Done() + value, err := cache.get("instance", func() (int, error) { + atomic.AddInt32(&fetches, 1) + <-release + return 42, nil + }) + // require's FailNow must not be called off the test goroutine. + assert.NoError(t, err) + results[idx] = value + }(i) + } + + // Let every goroutine reach the cache before the single fetch completes. + for atomic.LoadInt32(&fetches) == 0 { + } + close(release) + wg.Wait() + + require.Equal(t, int32(1), atomic.LoadInt32(&fetches), + "concurrent misses for one key must share a single control socket round-trip") + for _, value := range results { + require.Equal(t, 42, value) + } +} + +func TestSummaryCache_servesWithinTTLAndInvalidates(t *testing.T) { + cache := newSummaryCache[int]() + fetches := 0 + fetch := func() (int, error) { + fetches++ + return fetches, nil + } + + first, err := cache.get("inst", fetch) + require.NoError(t, err) + require.Equal(t, 1, first) + + second, err := cache.get("inst", fetch) + require.NoError(t, err) + require.Equal(t, 1, second, "a hit within the TTL must not refetch") + require.Equal(t, 1, fetches) + + cache.invalidate("inst") + third, err := cache.get("inst", fetch) + require.NoError(t, err) + require.Equal(t, 2, third, "invalidate must force the next read to refetch") +} + +func TestSummaryCache_invalidateIsScopedToTheInstance(t *testing.T) { + cache := newSummaryCache[int]() + fetches := map[string]int{} + fetchFor := func(key string) func() (int, error) { + return func() (int, error) { fetches[key]++; return fetches[key], nil } + } + + // "foo" plus one of its filter combinations, and a similarly named + // instance that must not be caught by the prefix match. + _, _ = cache.get("foo", fetchFor("foo")) + _, _ = cache.get("foo|flow-id-min=1", fetchFor("foo-filtered")) + _, _ = cache.get("foobar", fetchFor("foobar")) + + cache.invalidate("foo") + + _, _ = cache.get("foo", fetchFor("foo")) + _, _ = cache.get("foo|flow-id-min=1", fetchFor("foo-filtered")) + _, _ = cache.get("foobar", fetchFor("foobar")) + + require.Equal(t, 2, fetches["foo"]) + require.Equal(t, 2, fetches["foo-filtered"]) + require.Equal(t, 1, fetches["foobar"], "an instance with a shared name prefix must be left alone") +} + +func TestServer_overview_aggregatesCommandsIntoOneCall(t *testing.T) { + var issued []string + var mu sync.Mutex + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + mu.Lock() + issued = append(issued, command.Command) + mu.Unlock() + switch command.Command { + case "session-counters": + return []byte(`{"status":"ok","code":200,"session-counters":{"sessions":7}}`), nil + case "test-info": + return []byte(`{"status":"ok","code":200,"test-info":{"duration":12,"state":"active"}}`), nil + case "network-interfaces": + return []byte(`{"status":"ok","code":200,"network-interfaces":[{"name":"eth0"}]}`), nil + } + // The remaining interface commands are unsupported by this build. + return nil, fmt.Errorf("unknown command") + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances/test/_overview") + require.Equal(t, http.StatusOK, recorder.Code) + + var resp map[string]json.RawMessage + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.JSONEq(t, `{"sessions":7}`, string(resp["session-counters"])) + require.JSONEq(t, `{"duration":12,"state":"active"}`, string(resp["test-info"])) + require.JSONEq(t, `[{"name":"eth0"}]`, string(resp["network-interfaces"])) + // A command that fails individually yields null rather than failing the + // whole response, so the UI simply hides that section. + require.Equal(t, "null", string(resp["access-interfaces"])) + require.Equal(t, overviewCommands, issued) + + // The second request inside the cache period must not reach the socket + // again: this is the whole point of aggregating them. + mu.Lock() + issued = nil + mu.Unlock() + require.Equal(t, http.StatusOK, doGet(t, handler, "/api/v1/instances/test/_overview").Code) + require.Empty(t, issued) +} + +func TestServer_overview_notRunning(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + ExistsFunc: func(name string) bool { return true }, + CommandFunc: func(name string, command controller.SocketCommand) ([]byte, error) { + return nil, controller.ErrBlasterNotRunning + }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances/test/_overview") + require.Equal(t, http.StatusPreconditionFailed, recorder.Code) +} + +func TestServer_instances_detail(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + InstancesFunc: func() []string { return []string{"alpha", "beta"} }, + RunningFunc: func(name string) bool { return name == "beta" }, + } + handler := NewServer(repository) + + // Without the flag the response is the plain name array it has always been. + recorder := doGet(t, handler, "/api/v1/instances") + require.Equal(t, http.StatusOK, recorder.Code) + require.JSONEq(t, `["alpha","beta"]`, recorder.Body.String()) + + // With it, one request replaces the list plus one status call per instance. + recorder = doGet(t, handler, "/api/v1/instances?detail=true") + require.Equal(t, http.StatusOK, recorder.Code) + require.JSONEq(t, + `[{"name":"alpha","status":"stopped"},{"name":"beta","status":"started"}]`, + recorder.Body.String()) +} + +func TestServer_instances_detailOnEmptyListIsAnArray(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + InstancesFunc: func() []string { return nil }, + } + handler := NewServer(repository) + + recorder := doGet(t, handler, "/api/v1/instances?detail=true") + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, "[]", strings.TrimSpace(recorder.Body.String())) +} diff --git a/pkg/server/ui.go b/pkg/server/ui.go new file mode 100644 index 0000000..576c54e --- /dev/null +++ b/pkg/server/ui.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. +package server + +import ( + "bytes" + "embed" + "fmt" + "html/template" + "io/fs" + "net/http" + "strings" + "sync" + "time" +) + +// webUIAssets embeds the built-in single-page application that ships inside +// the bngblaster-controller binary. It is intentionally dependency-free +// (vanilla HTML/CSS/JS) so the controller remains a single static binary +// with no build step or external assets required at install time. +// +//go:embed webui/index.html webui/static +var webUIAssets embed.FS + +// processStartToken distinguishes one controller process from another when +// no meaningful release version is available (a "dev" build). It gives the +// asset version something that still changes across restarts, so a developer +// rebuilding the UI is never served a stale asset from the browser cache. +var processStartToken = fmt.Sprintf("dev-%d", time.Now().UnixNano()) + +// registerUIRoutes wires the embedded web UI into the router. It is only +// called when the UI is enabled (see WithUI). Static assets are served from +// "/static/...", the application shell from "/". +func (s *Server) registerUIRoutes() { + staticFS, err := fs.Sub(webUIAssets, "webui/static") + if err != nil { + // Cannot happen: the sub-directory is embedded at compile time. + panic(err) + } + + fileServer := http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))) + s.router.PathPrefix("/static/").Methods(http.MethodGet).Handler(s.cacheControl(fileServer)) + s.router.Path("/").Methods(http.MethodGet).Handler(s.index()) + s.router.Path("/favicon.ico").Methods(http.MethodGet).Handler(s.favicon()) +} + +// uiAssetVersion is the cache busting token appended to every asset URL the +// application shell emits. It is derived from the controller version, which +// is assigned after NewServer returns, so it is resolved lazily on first use +// and then kept for the lifetime of the process. +func (s *Server) uiAssetVersion() string { + s.assetVersionOnce.Do(func() { + version := strings.TrimSpace(s.Version) + if version == "" || version == "dev" { + s.assetVersion = processStartToken + return + } + s.assetVersion = version + }) + return s.assetVersion +} + +// indexTemplate renders the application shell. The only substitution is +// AssetVersion, used to version every asset URL. +var indexTemplate = sync.OnceValues(func() (*template.Template, error) { + content, err := webUIAssets.ReadFile("webui/index.html") + if err != nil { + return nil, err + } + return template.New("index").Parse(string(content)) +}) + +func (s *Server) index() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tmpl, err := indexTemplate() + if err != nil { + JSONError(w, "ui not available", http.StatusInternalServerError) + return + } + var rendered bytes.Buffer + if err := tmpl.Execute(&rendered, struct{ AssetVersion string }{s.uiAssetVersion()}); err != nil { + JSONError(w, "ui not available", http.StatusInternalServerError) + return + } + // The shell itself carries the asset version, so it must never be + // cached: a stale shell would keep pointing at the previous release's + // asset URLs and defeat the versioning entirely. + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Security-Policy", uiCSP) + w.Header().Set(contentType, "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(rendered.Bytes()) + } +} + +func (s *Server) favicon() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + content, err := webUIAssets.ReadFile("webui/static/img/logo.png") + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set(contentType, "image/png") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + } +} + +// cacheControl sets the caching policy for the embedded UI assets. Assets are +// compiled into the binary and have no file system timestamp, so the +// FileServer can offer neither Last-Modified nor a useful ETag; the freshness +// signal has to come from the URL instead. +// +// A request carrying the current asset version is immutable by construction - +// a new controller release produces a new version and therefore new URLs - so +// it may be cached indefinitely. Anything else (a bookmarked or hand-typed +// asset URL, or one left over from a previous release) must be revalidated, +// otherwise a browser could keep running a stale app.js against a newer API. +func (s *Server) cacheControl(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("v") == s.uiAssetVersion() { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "no-cache") + } + w.Header().Set("X-Content-Type-Options", "nosniff") + next.ServeHTTP(w, r) + }) +} diff --git a/pkg/server/ui_test.go b/pkg/server/ui_test.go new file mode 100644 index 0000000..204154a --- /dev/null +++ b/pkg/server/ui_test.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (C) 2020-2026, RtBrick, Inc. +package server + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/rtbrick/bngblaster-controller/pkg/controller" +) + +func uiServer(version string) *Server { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + } + server := NewServer(repository, WithUI(true)) + server.Version = version + return server +} + +func TestServer_index_versionsEveryAssetURL(t *testing.T) { + handler := uiServer("1.2.3") + + recorder := doGet(t, handler, "/") + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, "text/html; charset=utf-8", recorder.Header().Get("Content-Type")) + // The shell carries the asset version, so caching it would pin the + // browser to the previous release's asset URLs. + require.Equal(t, "no-store", recorder.Header().Get("Cache-Control")) + + body := recorder.Body.String() + require.NotContains(t, body, "{{", "the template must be fully rendered") + require.Contains(t, body, "/static/js/app.js?v=1.2.3") + require.Contains(t, body, "/static/css/app.css?v=1.2.3") +} + +func TestServer_index_devBuildsGetAPerProcessVersion(t *testing.T) { + // A "dev" build has no release version to key the cache off, so assets + // must still be re-fetched after a rebuild and restart. + body := doGet(t, uiServer("dev"), "/").Body.String() + require.Contains(t, body, "/static/js/app.js?v=dev-") +} + +func TestServer_staticAssets_cachePolicyFollowsTheVersion(t *testing.T) { + handler := uiServer("1.2.3") + + // The versioned URL the shell emits is immutable by construction. + versioned := doGet(t, handler, "/static/js/app.js?v=1.2.3") + require.Equal(t, http.StatusOK, versioned.Code) + require.Equal(t, "public, max-age=31536000, immutable", versioned.Header().Get("Cache-Control")) + require.Equal(t, "nosniff", versioned.Header().Get("X-Content-Type-Options")) + + // Anything else - a bookmark, or a URL left over from an older release - + // must be revalidated so a stale app.js never runs against a newer API. + for _, target := range []string{"/static/js/app.js", "/static/js/app.js?v=0.9.0"} { + stale := doGet(t, handler, target) + require.Equal(t, http.StatusOK, stale.Code) + require.Equal(t, "no-cache", stale.Header().Get("Cache-Control"), target) + } +} + +func TestServer_uiCanBeDisabled(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + } + handler := NewServer(repository, WithUI(false)) + + require.Equal(t, http.StatusNotFound, doGet(t, handler, "/").Code) + require.Equal(t, http.StatusNotFound, doGet(t, handler, "/static/js/app.js").Code) +} + +func TestServer_apiDocsAreServedIndependentlyOfTheUI(t *testing.T) { + repository := &controller.RepositoryMock{ + ConfigFolderFunc: func() string { return configFolder }, + } + handler := NewServer(repository, WithUI(false)) + + docs := doGet(t, handler, "/docs/swagger.yaml") + require.Equal(t, http.StatusOK, docs.Code) + require.Equal(t, "application/yaml", docs.Header().Get("Content-Type")) + require.True(t, strings.HasPrefix(docs.Body.String(), "openapi:"), + "expected the embedded OpenAPI document") + + require.Equal(t, http.StatusOK, doGet(t, handler, "/docs/").Code) +} diff --git a/pkg/server/webui/index.html b/pkg/server/webui/index.html new file mode 100644 index 0000000..69fcace --- /dev/null +++ b/pkg/server/webui/index.html @@ -0,0 +1,574 @@ + + + + + +BNG Blaster Controller + + + + + + + + + +
+ +
+

BNG Blaster Controller

+
Test Instance User Interface
+
+ +
+ BNG Blaster Docs + API Docs + version: … +
+ +
+ +
+ + +
+

Dashboard

+ +
+
+

Test Instances

+ +
+
+ + + + + + + + + + + +
List of bngblaster test instances and their current state
NameStatusActions
+ +
+
+ +
+ + + +
+ + + +
+
+

New Test Instance

+ +
+
+ +
+ + + Letters, digits, "-" and "_" only. +
+
+ + +
+
+ +
+

Loading configuration schema…

+
+
+ +
+ +
+
+ + + +
+

Start Instance

+ +
+
+ +
+ Reporting & logging +
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ Metrics +

Categories exposed on the Prometheus /metrics endpoint while running.

+
+
+
+ Overrides +
+
+ + + Leave at 0 to use the value from the instance configuration. +
+
+ + + A file inside this instance's folder; upload it first. +
+
+
+
+ +
+ + + +
+

Stream Detail

+ +
+
+
+
+ +
+ + + +
+

Session Detail

+ +
+
+
+
+ +
+ + + +
+

BGP Session Detail

+ +
+
+
+
+ +
+ + + +
+

Downloads

+ +
+
+ + + + + + +
+ +
+ + + +
+

Uploads

+ +
+
+

Upload stream definitions, MRT tables, BGP update files or other auxiliary + data used by a test configuration. Files are stored inside this instance's folder.

+
+

Drag and drop a file here, or press Enter / Space to browse.

+ +
+
    +
    + +
    + + + +
    +

    Please confirm

    + +
    +
    +

    +
    + +
    + + +
    +
    +

    Log Viewer

    + + + + +
    + +
    +
    +
    +
    + + + + diff --git a/pkg/server/webui/static/css/app.css b/pkg/server/webui/static/css/app.css new file mode 100644 index 0000000..78f5fc5 --- /dev/null +++ b/pkg/server/webui/static/css/app.css @@ -0,0 +1,886 @@ +/* BNG Blaster Controller — Web UI + Colors chosen for WCAG AA contrast (>= 4.5:1 for body text, >= 3:1 for large text/graphics). */ + +:root { + --color-bg: #f4f6f8; + --color-surface: #ffffff; + --color-surface-alt: #eef1f4; + --color-border: #c7ced6; + --color-text: #1a2027; + --color-text-muted: #4b5563; + --color-primary: #8a1c1c; /* RtBrick red, darkened for contrast on white */ + --color-primary-contrast: #ffffff; + --color-primary-hover: #6f1616; + --color-accent: #0b5fa5; + --color-success: #146c2e; + --color-warning: #d69429; + --color-danger: #a4222b; + --color-danger-hover: #841b23; + --color-focus: #0b5fa5; + --color-progress: #4b5563; + --radius: 6px; + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.5rem; + --space-6: 2rem; + --header-height: 3.5rem; + --logdock-height: 260px; + font-size: 16px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--color-bg); + color: var(--color-text); + font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + line-height: 1.45; +} + +body { padding-bottom: var(--logdock-height); } +body.logdock-collapsed { padding-bottom: 2.5rem; } + +h1, h2, h3, h4 { line-height: 1.2; margin: 0 0 var(--space-3); } +p { margin: 0 0 var(--space-3); } + +a { color: var(--color-accent); } + +/* Visible, high-contrast focus ring everywhere — never remove outline without replacing it. */ +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +[tabindex]:focus-visible, +summary:focus-visible { + outline: 3px solid var(--color-focus); + outline-offset: 2px; +} + +.visually-hidden { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: absolute; + left: -999px; + top: 0; + background: var(--color-primary); + color: var(--color-primary-contrast); + padding: var(--space-2) var(--space-4); + z-index: 1000; + border-radius: 0 0 var(--radius) 0; +} +.skip-link:focus { + left: 0; +} + +/* ---------- Header ---------- */ +.app-header { + height: var(--header-height); + display: flex; + align-items: center; + gap: var(--space-4); + padding: 0 var(--space-4); + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + z-index: 20; +} +.app-header .logo { height: 28px; width: auto; } +.app-header h1 { + font-size: 1.1rem; + font-weight: 600; + margin: 0; + color: var(--color-text); +} +.app-header .subtitle { + font-size: 0.8rem; + color: var(--color-text-muted); +} +.app-header .spacer { flex: 1; } +.app-header .version-badge { + font-size: 0.75rem; + color: var(--color-text-muted); + border: 1px solid var(--color-border); + padding: 0.15rem 0.5rem; + border-radius: 999px; +} +.app-header .api-docs-link { + font-size: 0.8rem; + font-weight: 600; + color: var(--color-accent); + text-decoration: none; +} +.app-header .api-docs-link:hover, +.app-header .api-docs-link:focus-visible { text-decoration: underline; } + +.app-nav { + display: flex; + gap: var(--space-2); +} +.app-nav button { + background: none; + border: 1px solid transparent; + padding: 0.4rem 0.75rem; + border-radius: var(--radius); + color: var(--color-text-muted); + font-weight: 600; + cursor: pointer; +} +.app-nav button[aria-current="page"] { + color: var(--color-primary); + border-color: var(--color-border); + background: var(--color-surface-alt); +} + +main { + max-width: 1400px; + margin: 0 auto; + padding: var(--space-5) var(--space-4); +} + +/* ---------- Buttons ---------- */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font: inherit; + font-weight: 600; + border-radius: var(--radius); + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + padding: 0.5rem 0.9rem; + cursor: pointer; + line-height: 1.1; + text-decoration: none; +} +.btn:hover { background: var(--color-surface-alt); } +.btn:disabled { opacity: 0.55; cursor: not-allowed; } +.btn-primary { + background: var(--color-primary); + border-color: var(--color-primary); + color: var(--color-primary-contrast); +} +.btn-primary:hover { background: var(--color-primary-hover); border-color: var(--color-primary-hover); } +.btn-danger { + background: var(--color-danger); + border-color: var(--color-danger); + color: #fff; +} +.btn-danger:hover { background: var(--color-danger-hover); } +.btn-sm { padding: 0.3rem 0.6rem; font-size: 0.85rem; } +.btn-icon { padding: 0.35rem; } + +/* ---------- Cards / sections ---------- */ +.card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--space-4); + margin-bottom: var(--space-5); +} +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-4); + flex-wrap: wrap; +} +.card-header h2 { margin: 0; font-size: 1.1rem; } + +/* ---------- Tables ---------- */ +table { width: 100%; border-collapse: collapse; } +caption { text-align: left; font-weight: 600; margin-bottom: var(--space-2); } +th, td { + text-align: left; + padding: 0.55rem 0.6rem; + border-bottom: 1px solid var(--color-border); + font-size: 0.92rem; +} +th { color: var(--color-text-muted); font-weight: 600; white-space: nowrap; } +tbody tr:hover { background: var(--color-surface-alt); } +.table-scroll { overflow-x: auto; } +.actions-cell { display: flex; gap: var(--space-2); flex-wrap: wrap; } + +.status-pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 700; + border: 1px solid transparent; +} +.status-pill.started { background: #e4f4e8; color: var(--color-success); border-color: #bfe4c9; } +.status-pill.stopped { background: #f0f0f0; color: var(--color-text-muted); border-color: var(--color-border); } +.status-pill.duration-pill { background: var(--color-surface-alt); color: var(--color-text-muted); border-color: var(--color-border); font-weight: 600; } +.status-pill.duration-pill::before { content: none; } +.status-pill::before { + content: ""; + width: 0.5rem; height: 0.5rem; + border-radius: 50%; + background: currentColor; +} + +.empty-state { + text-align: center; + color: var(--color-text-muted); + padding: var(--space-6) var(--space-4); +} + +/* ---------- Forms ---------- */ +.field { + margin-bottom: var(--space-3); +} +.field > label, .field > legend { + display: block; + font-weight: 600; + margin-bottom: 0.25rem; + font-size: 0.9rem; +} +.field .hint { + display: block; + color: var(--color-text-muted); + font-size: 0.8rem; + margin-top: 0.2rem; +} +input[type="text"], input[type="number"], input[type="search"], select, textarea { + font: inherit; + width: 100%; + padding: 0.5rem 0.6rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface); + color: var(--color-text); +} +textarea { min-height: 5rem; font-family: ui-monospace, Consolas, monospace; } +fieldset { + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--space-3) var(--space-4) var(--space-4); + margin: 0 0 var(--space-4); +} +fieldset fieldset { background: var(--color-surface-alt); } +.checkbox-field { display: flex; align-items: center; gap: 0.5rem; } +.checkbox-field input { width: auto; } +/* Sub-lists of enum flags (report/logging/metric flags in the Start Instance + dialog) nested under the checkbox that enables them. */ +.flags-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.25rem var(--space-3); + margin: 0.25rem 0 var(--space-3) 1.6rem; +} +.flags-grid .checkbox-field { font-size: 0.88rem; } +.required-mark { color: var(--color-danger); margin-left: 0.15rem; } +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--space-3) var(--space-4); +} +/* Structural sub-fields (nested objects rendered as
    , list/array + fields such as network/access/a10nsp/links/lag interfaces rendered as +
    ) always take the full row width and stack below the simple + scalar fields, instead of being squeezed into one ~220px grid column — + they typically hold many nested fields of their own and need the room. */ +.form-grid > details, +.form-grid > fieldset { + grid-column: 1 / -1; +} +.array-item { + display: flex; + gap: var(--space-2); + align-items: flex-end; + margin-bottom: var(--space-2); +} +.array-item > div { flex: 1; } +.form-error { + color: var(--color-danger); + font-size: 0.85rem; + margin-top: 0.25rem; +} +.form-status[role="alert"] { + padding: var(--space-2) var(--space-3); + border-radius: var(--radius); + margin-bottom: var(--space-3); +} +.form-status.error { background: #fbe7e8; color: var(--color-danger); border: 1px solid #f0c1c4; } +.form-status.success { background: #e4f4e8; color: var(--color-success); border: 1px solid #bfe4c9; } +.form-status-title { font-weight: 700; margin-bottom: 0.35rem; } +.form-status-detail { + margin: 0; + font-family: ui-monospace, Consolas, monospace; + font-size: 0.85rem; + white-space: pre-wrap; + word-break: break-word; +} + +/* ---------- Dialogs ---------- */ +dialog { + border: none; + border-radius: var(--radius); + padding: 0; + max-width: min(720px, 92vw); + width: 100%; + max-height: 88vh; + color: var(--color-text); + box-shadow: 0 10px 40px rgba(0,0,0,0.3); + /* Closed elements are display:none by default (UA stylesheet); + author rules always win over the UA stylesheet regardless of + selector specificity, so this MUST stay display:none here and only + switch to flex once [open] is set below — otherwise every dialog + renders inline in the page flow all the time instead of as a modal. */ + display: none; + flex-direction: column; +} +dialog[open] { display: flex; } +/* The New Instance dialog always uses (near) the full viewport — it hosts + the schema-driven form and the raw JSON editor, both of which need room. + Every other dialog (confirm, stream detail, start instance, ...) keeps + its compact, content-sized default above. */ +dialog.dialog-wide { + width: 98vw; + height: 94vh; + max-width: 98vw; + max-height: 94vh; +} +dialog::backdrop { background: rgba(15, 18, 22, 0.55); } +dialog > form[method="dialog"] { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} +.dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4); + border-bottom: 1px solid var(--color-border); + flex: 0 0 auto; +} +.dialog-header h2 { font-size: 1.1rem; margin: 0; } +.dialog-body { padding: var(--space-4); overflow-y: auto; flex: 1 1 auto; min-height: 0; } +.dialog-footer { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-4); + border-top: 1px solid var(--color-border); + flex: 0 0 auto; +} + +/* ---------- Configuration form: section index sidebar ---------- */ +.schema-form-layout { + display: flex; + align-items: flex-start; + gap: var(--space-4); +} +.schema-form-nav { + flex: 0 0 180px; + position: sticky; + top: 0; + display: flex; + flex-direction: column; + gap: 2px; + max-height: calc(94vh - 220px); + overflow-y: auto; +} +/* Author rules win over the UA [hidden] stylesheet regardless of + specificity (same pattern as the dialog [open] rule above), so the + "display: flex" above would otherwise keep the nav visible - and taking + up layout space - even while its "hidden" attribute is set in JSON + mode. */ +.schema-form-nav[hidden] { display: none; } +.schema-form-nav-link { + background: none; + border: none; + border-radius: var(--radius); + padding: 0.35rem 0.6rem; + text-align: left; + color: var(--color-text-muted); + cursor: pointer; + font-size: 0.88rem; +} +.schema-form-nav-link:hover, +.schema-form-nav-link:focus-visible { + background: var(--color-surface-alt); + color: var(--color-text); +} +.schema-form-content { + flex: 1 1 auto; + min-width: 0; +} +@media (max-width: 640px) { + .schema-form-layout { flex-direction: column; } + .schema-form-nav { + position: static; + flex-direction: row; + flex-wrap: wrap; + max-height: none; + width: 100%; + } +} + +/* ---------- Drop zone ---------- */ +.dropzone { + border: 2px dashed var(--color-border); + border-radius: var(--radius); + padding: var(--space-5); + text-align: center; + color: var(--color-text-muted); + background: var(--color-surface-alt); +} +.dropzone.dragover { + border-color: var(--color-accent); + color: var(--color-accent); + background: #eaf3fb; +} +.upload-list { margin-top: var(--space-3); font-size: 0.88rem; } +.upload-list li { display: flex; justify-content: space-between; gap: var(--space-2); padding: 0.2rem 0; } + +/* ---------- Config input mode toggle (Form / JSON) ---------- */ +.mode-toggle { + display: inline-flex; + gap: var(--space-1); + margin-bottom: var(--space-3); + background: var(--color-surface-alt); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 2px; +} +.mode-toggle button { + background: none; + border: none; + border-radius: calc(var(--radius) - 2px); + padding: 0.35rem 0.8rem; + font-weight: 600; + color: var(--color-text-muted); + cursor: pointer; +} +.mode-toggle button[aria-pressed="true"] { + background: var(--color-surface); + color: var(--color-primary); + box-shadow: 0 1px 2px rgba(0,0,0,0.15); +} +/* ---------- Schema-aware JSON editor ---------- */ +.json-editor { position: relative; } +#schema-json-textarea, +.json-editor-highlight { + margin: 0; + padding: 0.5rem 0.6rem; + border: 1px solid transparent; + border-radius: var(--radius); + font-family: ui-monospace, Consolas, monospace; + font-size: 0.85rem; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: break-word; + tab-size: 2; +} +#schema-json-textarea { + position: relative; + z-index: 1; + width: 100%; + min-height: 50vh; + background: transparent; + color: transparent; + caret-color: var(--color-text); + border-color: var(--color-border); + resize: vertical; +} +.json-editor-highlight { + position: absolute; + inset: 0; + z-index: 0; + overflow: auto; + pointer-events: none; + background: var(--color-surface); + color: var(--color-text); +} +.json-editor-highlight code { white-space: inherit; font: inherit; } +.jt-key { color: var(--color-accent); font-weight: 600; } +.jt-string { color: var(--color-success); } +.jt-number, .jt-boolean, .jt-null { color: #7a4a00; } +.jt-punct { color: var(--color-text-muted); } +.jt-error { text-decoration: underline wavy var(--color-danger); text-underline-offset: 2px; } + +.json-suggest { + position: absolute; + z-index: 5; + list-style: none; + margin: 0; + padding: 0.25rem 0; + min-width: 220px; + max-width: min(420px, 90vw); + max-height: 220px; + overflow-y: auto; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: 0 6px 20px rgba(0,0,0,0.2); +} +.json-suggest li { + padding: 0.3rem 0.6rem; + font-size: 0.85rem; + display: flex; + gap: 0.5rem; + align-items: baseline; + cursor: pointer; +} +.json-suggest li:hover, +.json-suggest li:focus-visible { + background: var(--color-surface-alt); +} +.json-suggest li .suggest-name { font-family: ui-monospace, Consolas, monospace; font-weight: 600; white-space: nowrap; } +.json-suggest li .suggest-desc { color: var(--color-text-muted); font-size: 0.78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.json-editor-info { + margin-top: var(--space-2); + padding: 0.4rem 0.6rem; + border-radius: var(--radius); + background: var(--color-surface-alt); + font-size: 0.82rem; + color: var(--color-text-muted); + min-height: 1.6em; +} +.json-editor-info strong { color: var(--color-text); font-family: ui-monospace, Consolas, monospace; } +.json-info-type { font-style: italic; } + +.json-problems { + list-style: none; + margin: var(--space-2) 0 0; + padding: 0; + max-height: 140px; + overflow-y: auto; +} +.json-problems:empty { display: none; margin: 0; } +.json-problems li { + padding: 0.25rem 0.5rem; + font-size: 0.82rem; + color: var(--color-danger); + cursor: pointer; + border-radius: var(--radius); +} +.json-problems li:hover { background: #fbe7e8; } +.json-problems li .problem-loc { color: var(--color-text-muted); margin-right: 0.4rem; font-family: ui-monospace, Consolas, monospace; } + +/* ---------- Tabs ---------- */ +[role="tablist"] { + display: flex; + gap: var(--space-2); + border-bottom: 1px solid var(--color-border); + margin-bottom: var(--space-4); + flex-wrap: wrap; +} +[role="tab"] { + background: none; + border: none; + border-bottom: 3px solid transparent; + padding: 0.6rem 0.2rem; + margin-right: var(--space-4); + font-weight: 600; + color: var(--color-text-muted); + cursor: pointer; +} +[role="tab"][aria-selected="true"] { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} +/* A tab that has nothing to show - Sessions during a pure stream test, or + Streams during a run with no traffic flows - is hidden outright. Stated + explicitly (rather than relying on the UA [hidden] rule) because the tab + strip is a flex container and the tabs carry author styling. */ +[role="tab"][hidden] { display: none; } +[role="tabpanel"] { outline: none; } +[role="tabpanel"][hidden] { display: none; } + +/* ---------- Progress bars (session overview) ---------- */ +.meter-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-4); +} +.stat-tile { + background: var(--color-surface-alt); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--space-3); +} +.stat-tile .stat-value { font-size: 1.6rem; font-weight: 700; } +.stat-tile .stat-label { font-size: 0.8rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.03em; } + +.session-meter { margin-bottom: var(--space-3); } +.session-meter .meter-label { + display: flex; + justify-content: space-between; + font-size: 0.85rem; + margin-bottom: 0.25rem; +} +.session-meter .meter-label .count { color: var(--color-text-muted); font-variant-numeric: tabular-nums; } +.session-meter progress { + width: 100%; + height: 1.1rem; + appearance: none; + border: 1px solid var(--color-border); + border-radius: 999px; + overflow: hidden; + background: var(--color-surface-alt); +} +.session-meter progress::-webkit-progress-bar { background: var(--color-surface-alt); } +.session-meter progress::-webkit-progress-value { background: var(--color-progress); } +.session-meter progress::-moz-progress-bar { background: var(--color-progress); } +.session-meter.is-complete progress::-webkit-progress-value { background: var(--color-success); } +.session-meter.is-complete progress::-moz-progress-bar { background: var(--color-success); } + +/* Sessions outstanding: reaching 100% means every session is stuck + outstanding, so that state is flagged red instead of green. */ +.session-meter--outstanding.is-complete progress::-webkit-progress-value { background: var(--color-progress); } +.session-meter--outstanding.is-complete progress::-moz-progress-bar { background: var(--color-progress); } + +/* Sessions terminated: always red (the same red used for the Kill/Delete + buttons), regardless of percentage. */ +.session-meter--terminated progress::-webkit-progress-value { background: var(--color-danger); } +.session-meter--terminated progress::-moz-progress-bar { background: var(--color-danger); } + +/* ---------- Interface statistics (Session Overview) ---------- */ +.iface-group { margin-top: var(--space-4); } +.iface-group h3 { margin-bottom: var(--space-2); } +.iface-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-2); +} +.iface-card { background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: var(--radius); padding: var(--space-3); } +.iface-card h4 { margin: 0 0 var(--space-2); font-size: 0.95rem; } +.iface-stat-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } +.iface-stat-table thead th { color: var(--color-text-muted); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.03em; text-align: left; padding-bottom: 0.3rem; } +.iface-stat-table th[scope="row"] { text-align: left; font-weight: 500; color: var(--color-text-muted); padding: 0.2rem 0.4rem 0.2rem 0; white-space: nowrap; } +.iface-stat-table td { padding: 0.2rem 0.4rem; font-variant-numeric: tabular-nums; } + +/* ---------- Virtual scroll stream table ---------- */ +.vscroll-viewport { + height: 420px; + overflow-y: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius); + position: relative; +} +.vscroll-spacer { width: 100%; } +.vscroll-row-table { width: 100%; border-collapse: collapse; table-layout: fixed; } +.vscroll-row-table th { position: sticky; top: 0; background: var(--color-surface); z-index: 1; } +.stream-row-loading td { color: var(--color-text-muted); font-style: italic; } +.rx-loss-nonzero { color: var(--color-danger); font-weight: 700; } +/* Start-or-Stop + Detail always stay on one row (the table uses a fixed row + height for virtual scrolling, so wrapping would break it). overflow-x + is deliberately left at its default (visible) rather than auto: a + scrollable overflow reserves scrollbar space that renders as a stray + line under this column alone, out of line with the row border under + every other column. + This flex layout lives on a div *inside* the , not the itself - + a table cell with display:flex forces the browser to reconcile flex + sizing with table row-height layout, which is exactly the kind of thing + that produces a 1px rounding gap between this column's row border and + every other column's, splitting one border line into two. + Shared by the stream and session virtual-scroll tables. */ +.row-actions-cell { + display: flex; + flex-wrap: nowrap; + gap: var(--space-1); +} +.row-actions-cell .btn { padding: 0.2rem 0.35rem; font-size: 0.78rem; white-space: nowrap; } + +/* ---------- Badges (shared pill style: command output meta, stream flags) ---------- */ +.badge { + display: inline-block; + padding: 0.1rem 0.55rem; + border-radius: 999px; + border: 1px solid var(--color-border); + font-weight: 600; +} +.badge.status-ok, .badge.badge-yes { background: #e4f4e8; color: var(--color-success); border-color: #bfe4c9; } +.badge.status-error, .badge.badge-no { background: #fbe4e4; color: var(--color-danger); border-color: #f0bcbc; } +.badge.status-code { background: var(--color-surface-alt); color: var(--color-text-muted); } + +/* ---------- Command builder ---------- */ +.command-output-meta { + display: flex; + gap: var(--space-2); + align-items: center; + margin-bottom: var(--space-2); + font-size: 0.85rem; +} +/* nowrap (not wrap) so a row with both badges is never taller than a row + with zero or one - the virtual-scroll spacer math above assumes every + row is exactly rowHeight tall, and a row that wraps to a second line + breaks that assumption, which is what made the scrollbar wobble once + scrolled to the end. */ +.stream-flags-cell { display: flex; gap: var(--space-1); flex-wrap: nowrap; } +.stream-flags-cell .badge { font-size: 0.72rem; padding: 0.05rem 0.35rem; white-space: nowrap; } +.command-output { + background: #0f1115; + color: #d8dee6; + padding: var(--space-3); + border-radius: var(--radius); + font-family: ui-monospace, Consolas, monospace; + font-size: 0.85rem; + overflow: auto; + max-height: 340px; + white-space: pre-wrap; + word-break: break-word; +} + +/* ---------- Detail dialogs (stream-info / session-info field lists) ---------- */ +/* One key/value pair per row: a label column sized to its content and a + value column taking the rest. Deliberately not .form-grid - that grid is + auto-fit/minmax for wrapping *complete* label+input fields, and applying + it here let dt/dd flow independently into it, packing two unrelated + key/value pairs (four grid cells) onto one row. */ +.detail-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: var(--space-2) var(--space-4); + align-items: baseline; +} +.detail-grid dt { font-weight: 600; color: var(--color-text-muted); white-space: nowrap; } +.detail-grid dd { margin: 0; overflow-wrap: anywhere; } +.detail-value-wide { grid-column: 1 / -1; } +.detail-value-json { + margin: 0.25rem 0 0; + padding: var(--space-2) var(--space-3); + background: #0f1115; + color: #d8dee6; + border-radius: var(--radius); + font-family: ui-monospace, Consolas, monospace; + font-size: 0.85rem; + overflow: auto; + max-height: 240px; + white-space: pre-wrap; + word-break: break-word; +} + +/* ---------- Log dock ---------- */ +.logdock { + position: fixed; + left: 0; right: 0; bottom: 0; + height: var(--logdock-height); + background: #11151a; + color: #d8dee6; + border-top: 2px solid var(--color-border); + display: flex; + flex-direction: column; + z-index: 30; +} +.logdock-collapsed .logdock { height: 2.5rem; } +.logdock-header { + display: flex; + align-items: center; + gap: var(--space-3); + padding: 0.4rem var(--space-4); + border-bottom: 1px solid #2a303a; + flex-shrink: 0; +} +.logdock-header h2 { font-size: 0.85rem; margin: 0; color: #d8dee6; text-transform: uppercase; letter-spacing: 0.04em; } +.logdock-header .spacer { flex: 1; } +.logdock-header .btn { background: #1c2128; color: #d8dee6; border-color: #2a303a; } +.logdock-header .btn:hover { background: #262c35; } +.logdock-body { + flex: 1; + overflow-y: auto; + padding: var(--space-2) var(--space-4); + font-family: ui-monospace, Consolas, monospace; + font-size: 0.82rem; +} +.logdock-collapsed .logdock-body, +.logdock-collapsed .logdock-footer { display: none; } +.log-line { white-space: pre-wrap; word-break: break-word; border-bottom: 1px solid rgba(255,255,255,0.04); padding: 0.1rem 0; } +.log-line.level-error, .log-line.level-err { color: #ff8a8a; } +.log-line.level-warn, .log-line.level-warning { color: #ffcf7a; } + +.sr-status { position: absolute; width: 1px; height: 1px; overflow: hidden; } + +@media (max-width: 720px) { + .app-header h1 { font-size: 0.95rem; } + .app-header .subtitle { display: none; } + :root { --logdock-height: 200px; } +} + +/* ===================== TOASTS ===================== */ +/* Visible counterpart of the #global-status screen-reader live region, so + that failures reported through announce() are not invisible to sighted + users. Sits above the log dock and below any open modal dialog. */ +.toast-region { + position: fixed; + top: var(--space-3); + right: var(--space-3); + z-index: 60; + display: flex; + flex-direction: column; + gap: var(--space-2); + width: min(24rem, calc(100vw - 2 * var(--space-3))); + pointer-events: none; +} + +.toast { + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius); + border: 1px solid var(--color-border); + border-left-width: 4px; + background: var(--color-surface); + box-shadow: 0 6px 20px rgb(0 0 0 / 18%); + font-size: 0.875rem; + line-height: 1.4; + animation: toast-in 120ms ease-out; +} + +.toast.error { border-left-color: var(--color-danger); } +.toast.success { border-left-color: var(--color-success); } +.toast.info { border-left-color: var(--color-primary); } + +.toast-message { + flex: 1; + overflow-wrap: anywhere; +} + +.toast-dismiss { + flex: none; + border: 0; + background: none; + cursor: pointer; + padding: 0 0.15rem; + font-size: 1rem; + line-height: 1; + color: var(--color-text-muted); +} + +.toast-dismiss:hover { color: var(--color-text); } + +@keyframes toast-in { + from { opacity: 0; transform: translateY(-0.35rem); } + to { opacity: 1; transform: none; } +} + +@media (prefers-reduced-motion: reduce) { + .toast { animation: none; } +} diff --git a/pkg/server/webui/static/img/logo.png b/pkg/server/webui/static/img/logo.png new file mode 100644 index 0000000..fcb016e Binary files /dev/null and b/pkg/server/webui/static/img/logo.png differ diff --git a/pkg/server/webui/static/js/app.js b/pkg/server/webui/static/js/app.js new file mode 100644 index 0000000..4b8874f --- /dev/null +++ b/pkg/server/webui/static/js/app.js @@ -0,0 +1,3189 @@ +// BNG Blaster Controller — Web UI application logic. +// Vanilla JS, no build step, no framework: this file is served as-is by the +// embedded controller binary. +(function () { + 'use strict'; + + //= ======================================================================== + // API client + //= ======================================================================== + const API = { + async fetchJSON(url, opts) { + const res = await fetch(url, opts); + if (res.status === 204 || res.status === 202) return null; + const text = await res.text(); + let body = null; + if (text) { + try { body = JSON.parse(text); } catch (e) { body = text; } + } + if (!res.ok) { + const msg = (body && body.message) ? body.message : (typeof body === 'string' ? body : res.statusText); + const err = new Error(msg || ('HTTP ' + res.status)); + err.status = res.status; + err.body = body; + throw err; + } + return body; + }, + version() { return this.fetchJSON('/api/v1/version'); }, + schema() { return this.fetchJSON('/api/v1/schema'); }, + interfaces() { return this.fetchJSON('/api/v1/interfaces'); }, + // detail=true returns [{name, status}] in one request instead of the + // plain name array plus one status request per instance. + instances(detail) { + return this.fetchJSON('/api/v1/instances' + (detail ? '?detail=true' : '')); + }, + status(name) { return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name)); }, + create(name, config) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + }, + getConfig(name) { + // config.json is served as a plain file (also used for the download + // link), so it doesn't follow the {status,message} JSON error contract + // used elsewhere and can't go through fetchJSON as-is. + return fetch('/api/v1/instances/' + encodeURIComponent(name) + '/config.json').then((res) => { + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.json(); + }); + }, + delete(name) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name), { method: 'DELETE' }); + }, + start(name, runningConfig) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(runningConfig), + }); + }, + stop(name) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_stop', { method: 'POST' }); + }, + kill(name) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_kill', { method: 'POST' }); + }, + command(name, command, args) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_command', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command: command, arguments: args || {} }), + }); + }, + streams(name, offset, limit, filters) { + const params = new URLSearchParams({ offset: String(offset), limit: String(limit) }); + Object.entries(filters || {}).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') params.set(k, v); + }); + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_streams?' + params.toString()); + }, + sessions(name, offset, limit, filters) { + const params = new URLSearchParams({ offset: String(offset), limit: String(limit) }); + Object.entries(filters || {}).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') params.set(k, v); + }); + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_sessions?' + params.toString()); + }, + // Aggregates session-counters, the three interface commands and + // test-info into one cached response, instead of five separate + // control-socket round-trips per poll per open browser tab. + overview(name) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_overview'); + }, + logs(name, offset, limit) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_logs?offset=' + offset + (limit ? '&limit=' + limit : '')); + }, + files(name) { + return this.fetchJSON('/api/v1/instances/' + encodeURIComponent(name) + '/_files'); + }, + fileDownloadURL(name, file) { + return '/api/v1/instances/' + encodeURIComponent(name) + '/_files/' + encodeURIComponent(file); + }, + upload(name, file) { + const fd = new FormData(); + fd.append('file', file); + return fetch('/api/v1/instances/' + encodeURIComponent(name) + '/_upload', { method: 'POST', body: fd }) + .then((res) => { + if (!res.ok) return res.text().then((t) => { throw new Error(t || res.statusText); }); + }); + }, + }; + + //= ======================================================================== + // Small DOM / a11y helpers + //= ======================================================================== + const $ = (sel, root) => (root || document).querySelector(sel); + const $all = (sel, root) => Array.from((root || document).querySelectorAll(sel)); + const el = (tag, attrs, children) => { + const node = document.createElement(tag); + Object.entries(attrs || {}).forEach(([k, v]) => { + if (v === undefined || v === null) return; + if (k === 'class') node.className = v; + else if (k === 'text') node.textContent = v; + else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v); + else node.setAttribute(k, v); + }); + (children || []).forEach((c) => { if (c) node.appendChild(c); }); + return node; + }; + + let idCounter = 0; + const nextId = (prefix) => prefix + '-' + (++idCounter); + + // The "instance is not running" paths overwrite these placeholders in + // place, so the original wording is captured once up front to be able to + // restore it when the view is reset for another instance. + const DEFAULT_EMPTY_TEXT = {}; + + //= ======================================================================== + // Polling + //= ======================================================================== + // Every recurring refresh in this UI goes through schedulePoll, which skips + // ticks while the browser tab is in the background. Without that, a + // forgotten tab keeps hammering the controller's unix control socket + // indefinitely - and the data it fetches is not being looked at anyway. + // Becoming visible again runs each active poll immediately, so the view is + // current by the time the user has finished switching to it rather than up + // to one interval stale. + const activePolls = new Set(); + + function schedulePoll(fn, intervalMs) { + const poll = { + fn: fn, + id: setInterval(() => { if (!document.hidden) fn(); }, intervalMs), + }; + activePolls.add(poll); + return poll; + } + + function cancelPoll(poll) { + if (!poll) return null; + clearInterval(poll.id); + activePolls.delete(poll); + return null; + } + + document.addEventListener('visibilitychange', () => { + if (document.hidden) return; + activePolls.forEach((poll) => poll.fn()); + }); + + const TOAST_TIMEOUT_MS = { error: 10000, success: 4000, info: 6000 }; + + // Shows a message as a visible toast. #global-status alone is a + // screen-reader-only live region, so anything announced through it used to + // be completely invisible to sighted users - which meant a failed stop, + // kill, delete or command reported nothing at all on screen. + function toast(message, kind) { + const region = $('#toast-region'); + if (!region) return; + const node = el('div', { class: 'toast ' + (kind || 'info') }, [ + el('div', { class: 'toast-message', text: message }), + ]); + const dismiss = () => { + if (node.parentNode) node.parentNode.removeChild(node); + clearTimeout(timer); + }; + node.appendChild(el('button', { + type: 'button', class: 'toast-dismiss', 'aria-label': 'Dismiss notification', + text: '\u2715', onclick: dismiss, + })); + region.appendChild(node); + // Errors linger noticeably longer: they usually carry something the user + // needs to read and act on, rather than a confirmation they can ignore. + const timer = setTimeout(dismiss, TOAST_TIMEOUT_MS[kind] || TOAST_TIMEOUT_MS.info); + // Never let the stack grow without bound during a burst of failures. + while (region.childElementCount > 5) region.removeChild(region.firstChild); + } + + // Announces a message to assistive technology *and* shows it on screen. + // kind is 'error' | 'success' | 'info' (default 'info'). + function announce(message, kind) { + const region = $('#global-status'); + region.textContent = ''; + // Force screen readers to re-announce even if the text is identical. + window.requestAnimationFrame(() => { region.textContent = message; }); + toast(message, kind); + } + + // Runs an action that talks to the controller, reporting both outcomes. + // Without this every caller had to remember its own try/catch; the ones + // that forgot (stop, kill) turned a failure into a silent unhandled + // promise rejection and left the UI showing the wrong state. + async function withFeedback(action, successMessage, failureMessage) { + try { + const result = await action(); + if (successMessage) announce(successMessage, 'success'); + return result; + } catch (e) { + announce(failureMessage + ': ' + e.message, 'error'); + return undefined; + } + } + + // Sets (or clears) a dialog's inline status/alert box with consistent + // error/success styling. Pass an empty message to clear it back to an + // invisible, unstyled state. + function setStatusMessage(el, message, kind) { + el.textContent = message || ''; + el.className = message ? ('form-status' + (kind ? ' ' + kind : '')) : ''; + } + + // A start/save failure can be the raw (possibly multi-line) stderr output + // of the bngblaster process itself - e.g. a JSON config validation error. + // That deserves more than a line of plain text quietly sitting in a small + // alert box: a clear heading plus a monospace, line-break-preserving + // block makes it obvious something failed and keeps the actual reason + // readable instead of being squashed onto one line. + function showDetailedError(box, title, message) { + box.innerHTML = ''; + box.className = 'form-status error'; + box.appendChild(el('div', { class: 'form-status-title', text: title })); + box.appendChild(el('pre', { class: 'form-status-detail', text: message })); + } + + function escapeHtml(str) { + return String(str).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + } + + //= ======================================================================== + // Dialog handling (native , with focus restore) + //= ======================================================================== + let lastFocusedBeforeDialog = null; + function openDialog(id) { + const dialog = document.getElementById(id); + lastFocusedBeforeDialog = document.activeElement; + dialog.showModal(); + const focusable = dialog.querySelector('input, select, textarea, button'); + if (focusable) focusable.focus(); + } + function closeDialog(id) { + const dialog = document.getElementById(id); + if (dialog.open) dialog.close(); + if (lastFocusedBeforeDialog && document.contains(lastFocusedBeforeDialog)) { + lastFocusedBeforeDialog.focus(); + } + } + document.addEventListener('click', (ev) => { + const trigger = ev.target.closest('[data-close-dialog]'); + if (trigger) closeDialog(trigger.getAttribute('data-close-dialog')); + }); + + function confirmAction(message) { + return new Promise((resolve) => { + $('#dialog-confirm-message').textContent = message; + openDialog('dialog-confirm'); + const dialog = $('#dialog-confirm'); + let confirmed = false; + const onConfirm = () => { confirmed = true; closeDialog('dialog-confirm'); }; + const onClose = () => { + dialog.removeEventListener('close', onClose); + $('#btn-confirm-ok').removeEventListener('click', onConfirm); + resolve(confirmed); + }; + $('#btn-confirm-ok').addEventListener('click', onConfirm); + dialog.addEventListener('close', onClose); + }); + } + + //= ======================================================================== + // Application state + //= ======================================================================== + const state = { + instances: [], + interfaces: [], + schema: null, + currentInstance: null, + instanceCommands: {}, // name -> normalized command list + // Drives the whole instance detail view: header badge on every tab, plus + // the Session Overview sections while that tab is visible. + overviewTimer: null, + stream: { instance: null, total: 0, rowHeight: 34, buffer: 8, pending: false, timer: null, pollTimer: null, filters: {}, detailFlowId: null, detailTimer: null }, + session: { instance: null, total: 0, rowHeight: 34, buffer: 8, pending: false, timer: null, pollTimer: null, filters: {}, detailSessionId: null, detailTimer: null }, + bgp: { pollTimer: null, detailKey: null, detailTimer: null }, + // generations maps instance -> the identity of the run.log it last read, + // so a restarted instance (which recreates the file) is detected. + log: { instance: null, offsets: {}, generations: {}, paused: false, manualSelect: false, timer: null }, + downloads: { instance: null }, + uploads: { instance: null }, + }; + + //= ======================================================================== + // Dashboard + //= ======================================================================== + async function refreshVersion() { + try { + const v = await API.version(); + $('#version-badge').textContent = 'controller ' + v['bngblasterctrl-version'] + ' · bngblaster ' + v['bngblaster-version']; + } catch (e) { /* non-fatal */ } + } + + async function loadInstances() { + let instances = []; + try { + instances = await API.instances(true) || []; + } catch (e) { + announce('Failed to load instances: ' + e.message, 'error'); + return; + } + state.instances = instances.map((i) => ({ name: i.name, status: i.status })); + renderInstancesTable(); + populateInstanceSelects(); + } + + function renderInstancesTable() { + const tbody = $('#instances-tbody'); + tbody.innerHTML = ''; + $('#instances-empty').hidden = state.instances.length > 0; + state.instances.forEach((inst) => { + const running = inst.status === 'started'; + const actions = el('td', { class: 'actions-cell' }, [ + el('button', { + class: 'btn btn-sm', type: 'button', text: 'Open', + onclick: () => openInstance(inst.name), + }), + running + ? el('button', { class: 'btn btn-sm', type: 'button', text: 'Stop', onclick: () => doStop(inst.name) }) + : el('button', { class: 'btn btn-sm btn-primary', type: 'button', text: 'Start', onclick: () => openStartDialog(inst.name) }), + running ? el('button', { class: 'btn btn-sm btn-danger', type: 'button', text: 'Kill', onclick: () => doKill(inst.name) }) : null, + !running ? el('button', { class: 'btn btn-sm', type: 'button', text: 'Edit', onclick: () => openInstanceDialog(inst.name) }) : null, + !running ? el('button', { class: 'btn btn-sm', type: 'button', text: 'Download', onclick: () => showDownloads(inst.name) }) : null, + !running ? el('button', { class: 'btn btn-sm', type: 'button', text: 'Upload', onclick: () => showUploads(inst.name) }) : null, + !running ? el('button', { class: 'btn btn-sm btn-danger', type: 'button', text: 'Delete', onclick: () => doDelete(inst.name) }) : null, + ]); + const row = el('tr', {}, [ + el('th', { scope: 'row', text: inst.name }), + el('td', {}, [el('span', { class: 'status-pill ' + (running ? 'started' : 'stopped'), text: running ? 'started' : 'stopped' })]), + actions, + ]); + tbody.appendChild(row); + }); + } + + function formatFileSize(bytes) { + if (!Number.isFinite(bytes)) return ''; + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit++; } + return (unit === 0 ? String(size) : size.toFixed(size < 10 ? 2 : 1)) + ' ' + units[unit]; + } + + // Downloads dialog: lists the files present in an instance's result + // folder (name, size, download button), mirroring the stream/session + // detail dialogs instead of opening a separate browser tab/window. + async function loadDownloads(showLoading) { + const name = state.downloads.instance; + if (!name) return; + const empty = $('#downloads-empty'); + const error = $('#downloads-error'); + const table = $('#downloads-table'); + if (showLoading) { + empty.hidden = true; + error.hidden = true; + table.hidden = true; + } + try { + const files = await API.files(name); + error.hidden = true; + if (!files || files.length === 0) { + table.hidden = true; + empty.hidden = false; + return; + } + empty.hidden = true; + const tbody = $('#downloads-tbody'); + tbody.innerHTML = ''; + files.forEach((f) => { + tbody.appendChild(el('tr', {}, [ + el('td', { text: f.name }), + el('td', { text: formatFileSize(f.size) }), + el('td', {}, [el('a', { + class: 'btn btn-sm', href: API.fileDownloadURL(name, f.name), download: f.name, text: 'Download', + })]), + ])); + }); + table.hidden = false; + } catch (e) { + table.hidden = true; + empty.hidden = true; + error.hidden = false; + error.textContent = 'Failed to load files: ' + e.message; + } + } + + function showDownloads(name) { + state.downloads.instance = name; + $('#dialog-downloads-title').textContent = 'Downloads — ' + name; + openDialog('dialog-downloads'); + loadDownloads(true); + } + + function showUploads(name) { + state.uploads.instance = name; + $('#dialog-uploads-title').textContent = 'Uploads — ' + name; + $('#upload-list').innerHTML = ''; + openDialog('dialog-uploads'); + } + + $('#btn-downloads-refresh').addEventListener('click', () => loadDownloads(true)); + + function populateInstanceSelects() { + const selects = [$('#logdock-instance-select')]; + selects.forEach((sel) => { + const previous = sel.value; + sel.innerHTML = ''; + if (state.instances.length === 0) { + sel.appendChild(el('option', { value: '', text: 'No instances available' })); + sel.disabled = true; + return; + } + sel.disabled = false; + state.instances.forEach((inst) => sel.appendChild(el('option', { value: inst.name, text: inst.name }))); + if (state.instances.some((i) => i.name === previous)) sel.value = previous; + }); + if (!state.log.manualSelect && state.currentInstance) { + $('#logdock-instance-select').value = state.currentInstance; + } + onLogInstanceChange(); + } + + async function doStop(name) { + await withFeedback(() => API.stop(name), 'Stop signal sent to ' + name, 'Failed to stop ' + name); + loadInstances(); + } + async function doKill(name) { + const ok = await confirmAction('Kill instance "' + name + '"? This sends SIGKILL immediately.'); + if (!ok) return; + await withFeedback(() => API.kill(name), 'Kill signal sent to ' + name, 'Failed to kill ' + name); + loadInstances(); + } + async function doDelete(name) { + const ok = await confirmAction('Delete instance "' + name + '" and all of its files? This cannot be undone.'); + if (!ok) return; + const deleted = await withFeedback( + () => API.delete(name).then(() => true), 'Deleted ' + name, 'Failed to delete ' + name); + if (deleted && renderedInstance === name) renderedInstance = null; + if (deleted && state.currentInstance === name) showDashboard(); + loadInstances(); + } + + //= ======================================================================== + // Start-instance dialog (RunningConfig) + //= ======================================================================== + // Enum options mirrored from the "_start" endpoint's request body schema + // (docs/swagger.yaml): report_flags, logging_flags and metric_flags. + const REPORT_FLAGS = ['sessions', 'streams']; + const LOGGING_FLAGS = [ + 'debug', 'error', 'igmp', 'io', 'pppoe', 'info', 'pcap', 'ip', 'loss', + 'l2tp', 'dhcp', 'isis', 'ospf', 'ldp', 'bgp', 'tcp', 'lag', 'dpdk', + 'af_xdp', 'packet', 'http', + ]; + const METRIC_FLAGS = ['session_counters', 'interfaces', 'access_interfaces', 'network_interfaces', 'a10nsp_interfaces', 'streams']; + + // Fills a .flags-grid container with one checkbox per flag. Guarded by + // childElementCount since the dialog markup and flag lists are static - + // this only ever needs to run once, not on every dialog open. + function renderFlagCheckboxes(containerId, idPrefix, flags, defaultChecked) { + const container = $('#' + containerId); + if (!container || container.childElementCount) return; + flags.forEach((flag) => { + const id = idPrefix + '-' + flag; + const cb = el('input', { type: 'checkbox', id, value: flag }); + cb.checked = defaultChecked.includes(flag); + container.appendChild(el('div', { class: 'checkbox-field' }, [cb, el('label', { for: id, text: flag })])); + }); + } + + function collectCheckedFlags(containerId) { + return Array.from($('#' + containerId).querySelectorAll('input[type="checkbox"]')) + .filter((cb) => cb.checked) + .map((cb) => cb.value); + } + + // Hides a flag sub-list while its enabling checkbox is unticked, so the + // options read as belonging to it rather than always-on settings. + function bindFlagsVisibility(toggleId, containerId) { + const toggle = $('#' + toggleId); + const container = $('#' + containerId); + const update = () => { container.hidden = !toggle.checked; }; + toggle.addEventListener('change', update); + update(); + } + + renderFlagCheckboxes('start-report-flags', 'start-report-flag', REPORT_FLAGS, []); + renderFlagCheckboxes('start-logging-flags', 'start-logging-flag', LOGGING_FLAGS, []); + renderFlagCheckboxes('start-metric-flags', 'start-metric-flag', METRIC_FLAGS, []); + bindFlagsVisibility('start-opt-report', 'start-report-flags'); + bindFlagsVisibility('start-opt-logging', 'start-logging-flags'); + + let startDialogTarget = null; + let startDialogThen = null; + function openStartDialog(name, andThen) { + startDialogTarget = name; + startDialogThen = andThen || null; + setStatusMessage($('#start-instance-status'), ''); + $('#dialog-start-instance-title').textContent = 'Start Instance — ' + name; + openDialog('dialog-start-instance'); + } + function collectRunningConfig() { + const sessionCount = parseInt($('#start-opt-session-count').value, 10) || 0; + return { + report: $('#start-opt-report').checked, + report_flags: $('#start-opt-report').checked ? collectCheckedFlags('start-report-flags') : [], + logging: $('#start-opt-logging').checked, + logging_flags: $('#start-opt-logging').checked ? collectCheckedFlags('start-logging-flags') : [], + pcap_capture: $('#start-opt-pcap').checked, + session_count: sessionCount, + stream_config: $('#start-opt-stream-config').value.trim(), + metric_flags: collectCheckedFlags('start-metric-flags'), + }; + } + $('#btn-start-instance-confirm').addEventListener('click', async () => { + const cfg = collectRunningConfig(); + try { + await API.start(startDialogTarget, cfg); + announce('Started ' + startDialogTarget, 'success'); + closeDialog('dialog-start-instance'); + if (startDialogThen) startDialogThen(); + loadInstances(); + if (state.currentInstance === startDialogTarget) refreshInstanceStatus(); + } catch (e) { + showDetailedError($('#start-instance-status'), 'Could not start instance', e.message); + } + }); + + //= ======================================================================== + // JSON Schema driven "New Instance" form + //= ======================================================================== + function resolveSchema(node, root) { + let n = node; + let guard = 0; + while (n && n.$ref && guard++ < 20) { + n = pointerGet(root, n.$ref); + } + if (n && Array.isArray(n.allOf)) { + const merged = Object.assign({}, n); + delete merged.allOf; + n.allOf.forEach((sub) => { + const resolved = resolveSchema(sub, root); + merged.properties = Object.assign({}, merged.properties, resolved.properties); + merged.required = (merged.required || []).concat(resolved.required || []); + if (!merged.type) merged.type = resolved.type; + }); + n = merged; + } + return n || {}; + } + + // Detects the "single item OR array of that item" oneOf pattern used + // throughout the bngblaster schema (network/access/a10nsp/links/lag, + // routing protocol blocks, http/icmp/arp clients, ...). Returns the raw + // (unresolved) item schema for the array alternative, or null. + function oneOfArrayItems(rawSchema, root) { + if (!rawSchema || !Array.isArray(rawSchema.oneOf) || rawSchema.oneOf.length !== 2) return null; + const arrayAlt = rawSchema.oneOf.find((alt) => resolveSchema(alt, root).type === 'array'); + if (!arrayAlt) return null; + const resolvedArrayAlt = resolveSchema(arrayAlt, root); + return arrayAlt.items || resolvedArrayAlt.items || null; + } + + function pointerGet(root, ref) { + if (!ref.startsWith('#/')) return {}; + const parts = ref.slice(2).split('/').map((p) => p.replace(/~1/g, '/').replace(/~0/g, '~')); + let node = root; + for (const p of parts) { + if (node == null) return {}; + node = node[p]; + } + return node || {}; + } + + function isInterfaceField(key) { + // "lag-interface" (on links) names a LAG group defined elsewhere in this + // same config, not a host NIC, so it must never pull from the + // host-interfaces dropdown. + if (key === 'lag-interface') return false; + return /(^|[-_])interface(name)?$/i.test(key); + } + + // A lagInterface object's own "interface" property is the name of the + // virtual LAG interface being created (e.g. "lag0") - it is picked by the + // user, not selected from the host's real network interfaces, so it must + // always render as a plain text field rather than the interfaces dropdown. + function isLagInterfaceDefSchema(schema) { + return !!(schema && schema.properties && schema.properties['lacp-min-active-links']); + } + + // Protocol/unit acronyms used throughout the bngblaster schema that should + // not be rendered with naive title-casing (e.g. "Pppoe", "Ipoe"). + const ACRONYMS = { + a10nsp: 'A10NSP', arp: 'ARP', as: 'AS', bgp: 'BGP', cfm: 'CFM', csnp: 'CSNP', + dhcp: 'DHCP', dhcpv6: 'DHCPv6', df: 'DF', dns1: 'DNS1', dns2: 'DNS2', dsl: 'DSL', + http: 'HTTP', https: 'HTTPS', ia: 'IA', icmp: 'ICMP', id: 'ID', igmp: 'IGMP', + io: 'IO', ip: 'IP', ip6cp: 'IP6CP', ipcp: 'IPCP', ipoe: 'IPoE', ipv4: 'IPv4', + ipv6: 'IPv6', ipv6pd: 'IPv6PD', isis: 'ISIS', l1: 'L1', l2: 'L2', l2tp: 'L2TP', + lacp: 'LACP', lag: 'LAG', lcp: 'LCP', ldp: 'LDP', ldra: 'LDRA', lsa: 'LSA', + lsp: 'LSP', lsr: 'LSR', mac: 'MAC', mrt: 'MRT', mru: 'MRU', mtu: 'MTU', + nat: 'NAT', ont: 'ONT', onu: 'ONU', ospf: 'OSPF', ospfv2: 'OSPFv2', ospfv3: 'OSPFv3', + p2p: 'P2P', pon: 'PON', ppp: 'PPP', pppoe: 'PPPoE', pps: 'pps', psnp: 'PSNP', + qinq: 'QinQ', rx: 'RX', sid: 'SID', sr: 'SR', tcp: 'TCP', tos: 'ToS', ttl: 'TTL', + tun: 'TUN', tx: 'TX', udp: 'UDP', url: 'URL', vlan: 'VLAN', + }; + + function prettyWord(word) { + if (!word) return word; + const canonical = ACRONYMS[word.toLowerCase()]; + if (canonical) return canonical; + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + } + + // Renders a schema key or raw identifier as a human label, applying the + // known protocol/unit acronyms (PPPoE, IPoE, VLAN, ...) instead of naive + // per-word title-casing. + function prettyLabel(text) { + return String(text).split(/[\s_-]+/).filter(Boolean).map(prettyWord).join(' '); + } + + function labelFor(key, schemaNode) { + return (schemaNode && schemaNode.title) || prettyLabel(key); + } + + // Builds a form field for a schema node. Returns { el, getValue } where + // getValue() returns undefined when the field should be omitted from the + // submitted document (untouched optional section, empty optional array...). + function buildField(key, rawSchema, root, required, opts) { + const arrayItems = oneOfArrayItems(rawSchema, root); + if (arrayItems) { + const arraySchema = { type: 'array', items: arrayItems, description: rawSchema.description }; + const field = buildArrayField(key, arraySchema, root, required, nextId('f'), labelFor(key, rawSchema)); + return { + el: field.el, + getValue: field.getValue, + // The real config (this is the "single item OR array of that item" + // oneOf pattern - network/access/a10nsp/links/lag, ...) may store a + // single object rather than an array, since the schema allows + // either. buildArrayField.setValue only understands arrays, so a + // bare object here must be normalized to a one-item array first - + // otherwise loading an existing single-interface config for editing + // would silently discard it, and saving would then write it out + // with that section missing entirely. + setValue: (v) => field.setValue(v === undefined || v === null || Array.isArray(v) ? v : [v]), + }; + } + + const schema = resolveSchema(rawSchema, root); + const type = schema.type || (schema.enum ? 'string' : 'object'); + const id = nextId('f'); + const label = labelFor(key, schema); + + if (type === 'object' && schema.properties) { + return buildObjectField(key, schema, root, required, id, label); + } + if (type === 'array') { + return buildArrayField(key, schema, root, required, id, label); + } + if (schema.enum) { + return buildEnumField(key, schema, required, id, label); + } + if (type === 'boolean') { + return buildBooleanField(key, schema, required, id, label); + } + if (type === 'integer' || type === 'number') { + return buildNumberField(key, schema, required, id, label, type === 'integer'); + } + if (isInterfaceField(key) && !(opts && opts.plainInterface)) { + return buildInterfaceField(key, schema, required, id, label); + } + return buildStringField(key, schema, required, id, label); + } + + function fieldWrap(id, label, required, hint, control) { + const wrap = el('div', { class: 'field' }); + wrap.appendChild(el('label', { for: id }, [ + document.createTextNode(label), + required ? el('span', { class: 'required-mark', 'aria-hidden': 'true', text: '*' }) : null, + ])); + wrap.appendChild(control); + if (hint) wrap.appendChild(el('span', { class: 'hint', text: hint })); + return wrap; + } + + // Leaf fields never bake a schema "default" into the generated config + // unless the field is required: bngblaster already applies its own + // defaults for any key that is simply absent, so silently emitting e.g. + // "cfm-cc": false for a field nobody touched only adds noise and risks + // diverging from the real default later. The default is still shown (as + // placeholder text, or a "Default: ..." hint where a placeholder isn't + // possible) purely for reference. Required fields keep the old + // pre-filled behavior since the config needs that key present regardless. + + function buildStringField(key, schema, required, id, label) { + const input = el('input', { type: 'text', id: id, required: required || null }); + if (schema.default !== undefined) { + if (required) input.value = schema.default; + else input.setAttribute('placeholder', String(schema.default)); + } + if (schema.pattern) input.setAttribute('pattern', schema.pattern); + const wrap = fieldWrap(id, label, required, schema.description, input); + return { + el: wrap, + getValue: () => (input.value.trim() === '' ? undefined : input.value), + setValue: (v) => { input.value = (v === undefined || v === null) ? '' : v; }, + }; + } + + function buildNumberField(key, schema, required, id, label, isInt) { + const input = el('input', { type: 'number', id: id, required: required || null }); + if (schema.minimum !== undefined) input.setAttribute('min', schema.minimum); + if (schema.maximum !== undefined) input.setAttribute('max', schema.maximum); + if (isInt) input.setAttribute('step', '1'); + if (schema.default !== undefined) { + if (required) input.value = schema.default; + else input.setAttribute('placeholder', String(schema.default)); + } + const wrap = fieldWrap(id, label, required, schema.description, input); + return { + el: wrap, + getValue: () => { + if (input.value.trim() === '') return undefined; + const n = Number(input.value); + return Number.isNaN(n) ? undefined : n; + }, + setValue: (v) => { input.value = (v === undefined || v === null) ? '' : v; }, + }; + } + + function buildBooleanField(key, schema, required, id, label) { + const input = el('input', { type: 'checkbox', id: id }); + const hasDefault = schema.default !== undefined; + // Shown for reference either way, but only *counts* as an explicit + // value once required, or once the user actually toggles it. + input.checked = hasDefault && schema.default === true; + let touched = !!required; + input.addEventListener('change', () => { touched = true; }); + const wrap = el('div', { class: 'field checkbox-field' }, [ + input, + el('label', { for: id, text: label }), + ]); + if (!required && hasDefault) wrap.appendChild(el('span', { class: 'hint', text: 'Default: ' + schema.default })); + if (schema.description) wrap.appendChild(el('span', { class: 'hint', text: schema.description })); + return { + el: wrap, + getValue: () => (touched ? input.checked : undefined), + setValue: (v) => { + const has = v !== undefined && v !== null; + touched = has || !!required; + input.checked = has ? !!v : (hasDefault && schema.default === true); + }, + }; + } + + function buildEnumField(key, schema, required, id, label) { + const select = el('select', { id: id, required: required || null }); + if (!required) select.appendChild(el('option', { value: '', text: '(not set)' })); + schema.enum.forEach((v) => select.appendChild(el('option', { value: v, text: String(v) }))); + const hasDefault = schema.default !== undefined; + if (hasDefault) select.value = schema.default; + // Same reference-only treatment as buildBooleanField: a pre-selected + // default shows what will apply, but doesn't count until touched. + let touched = !!required; + select.addEventListener('change', () => { touched = true; }); + const hint = (!required && hasDefault) + ? ('Default: ' + schema.default + (schema.description ? ' — ' + schema.description : '')) + : schema.description; + const wrap = fieldWrap(id, label, required, hint, select); + return { + el: wrap, + getValue: () => (touched && select.value !== '' ? select.value : undefined), + setValue: (v) => { + const has = v !== undefined && v !== null && schema.enum.some((e) => String(e) === String(v)); + touched = has || !!required; + if (has) select.value = String(v); + else select.value = hasDefault ? String(schema.default) : ''; + }, + }; + } + + function buildInterfaceField(key, schema, required, id, label) { + const select = el('select', { id: id }); + if (!required) select.appendChild(el('option', { value: '', text: '(not set)' })); + state.interfaces.forEach((iface) => select.appendChild(el('option', { value: iface.name, text: iface.name + (iface.mtu ? ' (mtu ' + iface.mtu + ')' : '') }))); + select.appendChild(el('option', { value: '__other__', text: 'Other (type manually)…' })); + const manual = el('input', { type: 'text', id: id + '-manual', class: 'visually-hidden', 'aria-label': label + ' (manual value)' }); + select.addEventListener('change', () => { + const isOther = select.value === '__other__'; + manual.classList.toggle('visually-hidden', !isOther); + if (isOther) manual.focus(); + }); + const container = el('div', {}, [select, manual]); + const wrap = fieldWrap(id, label, required, schema.description || 'Populated from the host network interfaces detected by the controller.', container); + return { + el: wrap, + getValue: () => { + if (select.value === '') return undefined; + if (select.value === '__other__') return manual.value.trim() === '' ? undefined : manual.value.trim(); + return select.value; + }, + setValue: (v) => { + if (v === undefined || v === null || v === '') { + select.value = ''; manual.value = ''; manual.classList.add('visually-hidden'); + return; + } + const hasOption = Array.from(select.options).some((o) => o.value === v); + if (hasOption) { + select.value = v; + manual.value = ''; manual.classList.add('visually-hidden'); + } else { + select.value = '__other__'; + manual.value = v; manual.classList.remove('visually-hidden'); + } + }, + }; + } + + function buildObjectField(key, schema, root, required, id, label) { + const requiredChildren = schema.required || []; + const isLagInterfaceDef = isLagInterfaceDefSchema(schema); + const body = el('div', { class: 'form-grid' }); + const children = Object.entries(schema.properties).map(([childKey, childSchema]) => { + const opts = (childKey === 'interface' && isLagInterfaceDef) ? { plainInterface: true } : undefined; + const field = buildField(childKey, childSchema, root, requiredChildren.includes(childKey), opts); + body.appendChild(field.el); + return [childKey, field]; + }); + const details = el('details', { open: required ? '' : null }); + details.appendChild(el('summary', {}, [document.createTextNode(label + (required ? ' *' : ''))])); + if (schema.description) details.appendChild(el('p', { class: 'hint', text: schema.description })); + details.appendChild(body); + return { + el: details, + getValue: () => { + const obj = {}; + let any = false; + children.forEach(([childKey, field]) => { + const v = field.getValue(); + if (v !== undefined) { obj[childKey] = v; any = true; } + }); + if (!any && !required) return undefined; + return obj; + }, + setValue: (v) => { + const has = v !== null && typeof v === 'object'; + children.forEach(([childKey, field]) => { if (field.setValue) field.setValue(has ? v[childKey] : undefined); }); + if (has) details.open = true; + }, + }; + } + + function buildArrayField(key, schema, root, required, id, label) { + const itemSchema = resolveSchema(schema.items || {}, root); + const fieldset = el('fieldset', {}); + fieldset.appendChild(el('legend', { text: label + (required ? ' *' : '') })); + if (schema.description) fieldset.appendChild(el('p', { class: 'hint', text: schema.description })); + + // Enumerated string arrays are rendered as a checkbox group (e.g. flags). + if (itemSchema.type === 'string' && Array.isArray(itemSchema.enum)) { + const boxes = itemSchema.enum.map((v) => { + const cbId = nextId('f'); + const cb = el('input', { type: 'checkbox', id: cbId, value: v }); + fieldset.appendChild(el('div', { class: 'checkbox-field' }, [cb, el('label', { for: cbId, text: v })])); + return cb; + }); + return { + el: fieldset, + getValue: () => { + const values = boxes.filter((b) => b.checked).map((b) => b.value); + return values.length ? values : (required ? [] : undefined); + }, + setValue: (arr) => { + const values = (Array.isArray(arr) ? arr : []).map(String); + boxes.forEach((b) => { b.checked = values.includes(b.value); }); + }, + }; + } + + const itemsContainer = el('div', {}); + fieldset.appendChild(itemsContainer); + const items = []; + + function addItem(initialValue) { + const field = buildField(key + ' item', schema.items || {}, root, false); + if (initialValue !== undefined && field.setValue) field.setValue(initialValue); + const row = el('div', { class: 'array-item' }, [ + field.el, + el('button', { + type: 'button', class: 'btn btn-sm', 'aria-label': 'Remove ' + label + ' item', + text: 'Remove', + onclick: () => { itemsContainer.removeChild(row); const i = items.indexOf(field); if (i >= 0) items.splice(i, 1); }, + }), + ]); + itemsContainer.appendChild(row); + items.push(field); + } + + fieldset.appendChild(el('button', { + type: 'button', class: 'btn btn-sm', text: 'Add ' + label + ' item', + onclick: () => addItem(), + })); + if ((schema.minItems || 0) > 0) { + for (let i = 0; i < schema.minItems; i++) addItem(); + } + + return { + el: fieldset, + getValue: () => { + const values = items.map((f) => f.getValue()).filter((v) => v !== undefined); + return values.length ? values : (required ? [] : undefined); + }, + setValue: (arr) => { + itemsContainer.innerHTML = ''; + items.length = 0; + (Array.isArray(arr) ? arr : []).forEach((v) => addItem(v)); + }, + }; + } + + // newInstanceFields holds the [key, field] pairs of the schema-driven form + // when the schema loaded successfully; null when only JSON editing is + // available (schema missing / failed to load / has no top-level + // properties). + let newInstanceFields = null; + let configMode = 'form'; // 'form' | 'json' + + function debounce(fn, ms) { + let t; + return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; + } + + function collectFormJSON() { + const obj = {}; + (newInstanceFields || []).forEach(([k, f]) => { const v = f.getValue(); if (v !== undefined) obj[k] = v; }); + return obj; + } + + function applyJSONToForm(obj) { + (newInstanceFields || []).forEach(([k, f]) => { if (f.setValue) f.setValue(obj ? obj[k] : undefined); }); + } + + // Switches the New Instance dialog between the schema-driven form and raw + // JSON editing, synchronizing the two representations at the switch point + // (rather than continuously, which would be surprising while typing). + function switchConfigMode(mode) { + const jsonStatus = $('#schema-json-status'); + if (mode === configMode) return; + if (mode === 'json') { + $('#schema-json-textarea').value = JSON.stringify(collectFormJSON(), null, 2); + placeJSONEditorCaretInsideRoot($('#schema-json-textarea')); + refreshJSONEditor(); + setStatusMessage(jsonStatus, ''); + } else { + let parsed; + try { + const text = $('#schema-json-textarea').value.trim(); + parsed = text ? JSON.parse(text) : {}; + } catch (e) { + setStatusMessage(jsonStatus, 'Cannot switch to the form view: invalid JSON (' + e.message + ').', 'error'); + return; + } + applyJSONToForm(parsed); + setStatusMessage(jsonStatus, ''); + } + configMode = mode; + $('#config-mode-btn-form').setAttribute('aria-pressed', String(mode === 'form')); + $('#config-mode-btn-json').setAttribute('aria-pressed', String(mode === 'json')); + $('#schema-form-root').hidden = mode !== 'form'; + $('#schema-json-root').hidden = mode !== 'json'; + const nav = $('#schema-form-nav'); + if (nav) nav.hidden = mode !== 'form' || !nav.childElementCount; + if (mode === 'json') $('#schema-json-textarea').focus(); + } + $('#config-mode-btn-form').addEventListener('click', () => switchConfigMode('form')); + $('#config-mode-btn-json').addEventListener('click', () => switchConfigMode('json')); + + // Hides the Form/JSON toggle and keeps only the JSON editor visible, used + // when there is no usable schema to drive a form from. + function forceJSONOnlyMode() { + newInstanceFields = null; + configMode = 'json'; + $('#config-mode-toggle').hidden = true; + $('#schema-json-root').hidden = false; + const nav = $('#schema-form-nav'); + if (nav) { nav.hidden = true; nav.innerHTML = ''; } + } + + //= ======================================================================== + // JSON text editor: schema-aware syntax highlighting, live validation, + // property/enum autocomplete and a cursor-position schema info panel for + // the "Edit as JSON" view. Works even without state.schema (highlighting + // and JSON syntax errors only); schema-driven features (validation beyond + // syntax, autocomplete, info panel) activate once state.schema is set. + //= ======================================================================== + function jsonTokenize(text) { + const tokens = []; + let i = 0; + const n = text.length; + const isDigitStart = (c) => c === '-' || (c >= '0' && c <= '9'); + while (i < n) { + const c = text[i]; + if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; } + if (c === '{' || c === '}' || c === '[' || c === ']' || c === ':' || c === ',') { + tokens.push({ type: 'punct', value: c, start: i, end: i + 1 }); + i++; + continue; + } + if (c === '"') { + const start = i; + i++; + let terminated = false; + while (i < n) { + if (text[i] === '\\') { i += 2; continue; } + if (text[i] === '"') { i++; terminated = true; break; } + if (text[i] === '\n') break; + i++; + } + tokens.push({ type: 'string', start, end: i, terminated }); + continue; + } + if (isDigitStart(c)) { + const start = i; + i++; + while (i < n && /[0-9.eE+-]/.test(text[i])) i++; + tokens.push({ type: 'number', start, end: i }); + continue; + } + if (/[a-zA-Z_]/.test(c)) { + const start = i; + i++; + while (i < n && /[a-zA-Z_0-9]/.test(text[i])) i++; + const word = text.slice(start, i); + const type = (word === 'true' || word === 'false') ? 'boolean' : (word === 'null' ? 'null' : 'ident'); + tokens.push({ type, value: word, start, end: i }); + continue; + } + tokens.push({ type: 'invalid', value: c, start: i, end: i + 1 }); + i++; + } + return tokens; + } + + function decodeJSONStringToken(text, tok) { + let raw = text.slice(tok.start, tok.end); + if (!tok.terminated) raw += '"'; + try { + return JSON.parse(raw); + } catch (e) { + return raw.slice(1, -1); + } + } + + // Lenient recursive-descent JSON parser with error recovery: instead of + // throwing on the first problem (like JSON.parse), it records an error and + // keeps going, so the rest of an in-progress edit still gets highlighted + // and validated. Produces an AST annotated with source offsets, and tags + // object-key string tokens with role:'key' (used to tell keys from string + // values apart when rendering syntax highlighting). + function jsonParseLenient(text) { + const tokens = jsonTokenize(text); + let pos = 0; + const errors = []; + const peek = () => tokens[pos]; + const next = () => tokens[pos++]; + const err = (tok, msg) => errors.push({ start: tok ? tok.start : text.length, end: tok ? tok.end : text.length, message: msg }); + + function parseValue() { + const tok = peek(); + if (!tok) { err(null, 'Unexpected end of input.'); return null; } + if (tok.type === 'punct' && tok.value === '{') return parseObject(); + if (tok.type === 'punct' && tok.value === '[') return parseArray(); + if (tok.type === 'string') { next(); return { type: 'string', start: tok.start, end: tok.end, raw: tok }; } + if (tok.type === 'number') { next(); return { type: 'number', start: tok.start, end: tok.end, value: Number(text.slice(tok.start, tok.end)) }; } + if (tok.type === 'boolean') { next(); return { type: 'boolean', start: tok.start, end: tok.end, value: tok.value === 'true' }; } + if (tok.type === 'null') { next(); return { type: 'null', start: tok.start, end: tok.end }; } + err(tok, 'Unexpected token "' + (tok.value || text.slice(tok.start, tok.end)) + '".'); + next(); + return null; + } + function parseObject() { + const open = next(); + const node = { type: 'object', start: open.start, end: open.end, entries: [] }; + let first = true; + for (;;) { + let tok = peek(); + if (!tok) { err(null, 'Unterminated object.'); break; } + if (tok.type === 'punct' && tok.value === '}') { next(); node.end = tok.end; break; } + if (!first) { + if (tok.type === 'punct' && tok.value === ',') { + next(); + tok = peek(); + if (tok && tok.type === 'punct' && tok.value === '}') err(tok, 'Trailing comma is not allowed.'); + } else { + err(tok, 'Expected "," or "}".'); + } + } + first = false; + if (!tok) break; + if (tok.type === 'punct' && tok.value === '}') { next(); node.end = tok.end; break; } + if (tok.type !== 'string') { err(tok, 'Expected a property name.'); next(); continue; } + const keyTok = next(); + keyTok.role = 'key'; + const keyName = decodeJSONStringToken(text, keyTok); + const colon = peek(); + if (colon && colon.type === 'punct' && colon.value === ':') next(); + else err(colon, 'Expected ":".'); + const valueNode = parseValue(); + node.entries.push({ key: keyName, keyStart: keyTok.start, keyEnd: keyTok.end, value: valueNode }); + node.end = valueNode ? valueNode.end : keyTok.end; + } + return node; + } + function parseArray() { + const open = next(); + const node = { type: 'array', start: open.start, end: open.end, items: [] }; + let first = true; + for (;;) { + let tok = peek(); + if (!tok) { err(null, 'Unterminated array.'); break; } + if (tok.type === 'punct' && tok.value === ']') { next(); node.end = tok.end; break; } + if (!first) { + if (tok.type === 'punct' && tok.value === ',') { + next(); + tok = peek(); + if (tok && tok.type === 'punct' && tok.value === ']') err(tok, 'Trailing comma is not allowed.'); + } else { + err(tok, 'Expected "," or "]".'); + } + } + first = false; + if (tok && tok.type === 'punct' && tok.value === ']') { next(); node.end = tok.end; break; } + const itemNode = parseValue(); + node.items.push(itemNode); + node.end = itemNode ? itemNode.end : node.end; + } + return node; + } + + let root = null; + if (tokens.length) { + root = parseValue(); + if (pos < tokens.length) err(peek(), 'Unexpected trailing content.'); + } + return { root, errors, tokens }; + } + + function jsonSchemaTypesOf(schema) { + if (!schema) return null; + if (schema.type) return Array.isArray(schema.type) ? schema.type : [schema.type]; + return null; + } + + // Validates a parsed AST node against a (possibly $ref/allOf/oneOf-array) + // schema, appending {start, end, message} problems to `out`. Deliberately + // covers the constraint kinds actually used by the bngblaster schema + // (type, enum, required, additionalProperties, pattern/length, min/max, + // array size) rather than the full JSON Schema spec. + function validateJSONAgainstSchema(text, node, rawSchema, root, path, out) { + if (!node || !rawSchema) return; + const arrayAlt = oneOfArrayItems(rawSchema, root); + if (arrayAlt) { + if (node.type === 'array') { validateJSONAgainstSchema(text, node, { type: 'array', items: arrayAlt }, root, path, out); return; } + validateJSONAgainstSchema(text, node, arrayAlt, root, path, out); + return; + } + const schema = resolveSchema(rawSchema, root); + const types = jsonSchemaTypesOf(schema); + const actual = node.type; + const label = path || '(root)'; + if (types) { + const ok = types.includes(actual) || (actual === 'number' && types.includes('integer') && Number.isInteger(node.value)); + if (!ok) { + const at = (actual === 'object' || actual === 'array') ? [node.start, node.start + 1] : [node.start, node.end]; + out.push({ start: at[0], end: at[1], message: label + ': expected ' + types.join(' or ') + ', got ' + actual + '.' }); + return; + } + } + if (schema.enum) { + const val = actual === 'string' ? decodeJSONStringToken(text, node.raw) : (actual === 'number' || actual === 'boolean' ? node.value : null); + if (!schema.enum.some((e) => e === val)) { + out.push({ start: node.start, end: node.end, message: label + ': must be one of ' + schema.enum.map((e) => JSON.stringify(e)).join(', ') + '.' }); + } + } + if (actual === 'string') { + const val = decodeJSONStringToken(text, node.raw); + if (schema.pattern) { + try { + if (!new RegExp(schema.pattern).test(val)) out.push({ start: node.start, end: node.end, message: label + ': does not match pattern ' + schema.pattern + '.' }); + } catch (e) { /* invalid pattern in the schema itself - nothing to check */ } + } + if (schema.minLength !== undefined && val.length < schema.minLength) out.push({ start: node.start, end: node.end, message: label + ': must be at least ' + schema.minLength + ' characters.' }); + if (schema.maxLength !== undefined && val.length > schema.maxLength) out.push({ start: node.start, end: node.end, message: label + ': must be at most ' + schema.maxLength + ' characters.' }); + } + if (actual === 'number') { + if (schema.minimum !== undefined && node.value < schema.minimum) out.push({ start: node.start, end: node.end, message: label + ': must be ≥ ' + schema.minimum + '.' }); + if (schema.maximum !== undefined && node.value > schema.maximum) out.push({ start: node.start, end: node.end, message: label + ': must be ≤ ' + schema.maximum + '.' }); + } + if (actual === 'object') { + const props = schema.properties || {}; + const required = schema.required || []; + const seen = new Set(); + node.entries.forEach((entry) => { + seen.add(entry.key); + const childSchema = props[entry.key]; + if (!childSchema) { + if (schema.additionalProperties === false) { + out.push({ start: entry.keyStart, end: entry.keyEnd, message: label + ': unknown property "' + entry.key + '".' }); + } + return; + } + if (entry.value) validateJSONAgainstSchema(text, entry.value, childSchema, root, path ? path + '.' + entry.key : entry.key, out); + }); + required.forEach((key) => { + if (!seen.has(key)) { + const at = node.end > node.start ? [Math.max(node.start, node.end - 1), node.end] : [node.start, node.end]; + out.push({ start: at[0], end: at[1], message: label + ': missing required property "' + key + '".' }); + } + }); + } + if (actual === 'array') { + const itemSchema = schema.items; + if (schema.minItems !== undefined && node.items.length < schema.minItems) out.push({ start: node.start, end: node.start + 1, message: label + ': must have at least ' + schema.minItems + ' item(s).' }); + if (schema.maxItems !== undefined && node.items.length > schema.maxItems) out.push({ start: node.start, end: node.start + 1, message: label + ': must have at most ' + schema.maxItems + ' item(s).' }); + if (itemSchema) node.items.forEach((item, idx) => { if (item) validateJSONAgainstSchema(text, item, itemSchema, root, path + '[' + idx + ']', out); }); + } + } + + // Walks the token stream up to `offset` with a small stack machine + // (mirroring the object/array nesting) to determine the JSON path and + // schema in scope at the cursor, tolerating an in-progress/invalid + // document around the cursor itself (the token currently being typed, + // "partial", is deliberately excluded from the walk). + function computeJSONCursorContext(text, offset, rootSchema) { + const tokens = jsonTokenize(text); + const partial = tokens.find((t) => { + if (t.type === 'punct') return false; + if (t.start < offset && offset < t.end) return true; + if (t.type === 'string' && !t.terminated && t.start < offset && offset <= t.end) return true; + return false; + }) || null; + const consumed = tokens.filter((t) => t !== partial && t.end <= offset); + + const rootEff = rootSchema ? resolveSchema(rootSchema, rootSchema) : null; + const stack = [{ kind: 'root', rawSchema: rootSchema, effective: rootEff, path: '', keys: null, index: 0 }]; + let pendingKey = null; + let expect = 'value'; + + function schemaForChild(top, key) { + if (top.kind === 'object') { + const props = (top.effective && top.effective.properties) || {}; + if (Object.prototype.hasOwnProperty.call(props, key)) return props[key]; + if (top.effective && top.effective.additionalProperties && typeof top.effective.additionalProperties === 'object') return top.effective.additionalProperties; + return null; + } + if (top.kind === 'array') return (top.effective && top.effective.items) || null; + return null; + } + + consumed.forEach((t) => { + const top = stack[stack.length - 1]; + if (t.type === 'punct') { + if (t.value === '{' || t.value === '[') { + const isObj = t.value === '{'; + const key = pendingKey; + const idx = top.index || 0; + const childRaw = top.kind === 'root' ? top.rawSchema : schemaForChild(top, key); + let effChild = childRaw ? resolveSchema(childRaw, rootSchema) : null; + const arrAlt = childRaw ? oneOfArrayItems(childRaw, rootSchema) : null; + if (arrAlt) effChild = isObj ? resolveSchema(arrAlt, rootSchema) : { type: 'array', items: arrAlt }; + const childPath = top.kind === 'array' ? top.path + '[' + idx + ']' : (top.path ? top.path + '.' + key : (key || '')); + stack.push({ kind: isObj ? 'object' : 'array', rawSchema: childRaw, effective: effChild, path: childPath, keys: isObj ? new Set() : null, index: 0 }); + pendingKey = null; + expect = isObj ? 'key-or-close' : 'value-or-close'; + } else if (t.value === '}' || t.value === ']') { + if (stack.length > 1) stack.pop(); + expect = 'comma-or-close'; + } else if (t.value === ':') { + expect = 'value'; + } else if (t.value === ',') { + if (top.kind === 'array') top.index = (top.index || 0) + 1; + expect = top.kind === 'object' ? 'key-or-close' : 'value-or-close'; + pendingKey = null; + } + } else if (t.type === 'string') { + if (top.kind === 'object' && expect === 'key-or-close') { + pendingKey = decodeJSONStringToken(text, t); + top.keys.add(pendingKey); + expect = 'colon'; + } else { + expect = 'comma-or-close'; + } + } else { + expect = 'comma-or-close'; + } + }); + + return { tokens, partial, stack, top: stack[stack.length - 1], pendingKey, expect }; + } + + // Property-name / enum-value suggestions for the cursor's current context, + // or null when nothing sensible applies (e.g. no schema loaded, or the + // cursor sits somewhere autocomplete doesn't help such as mid-punctuation). + function jsonAutocompleteSuggestions(text, offset, rootSchema) { + if (!rootSchema) return null; + const ctx = computeJSONCursorContext(text, offset, rootSchema); + const top = ctx.top; + const partial = ctx.partial; + + // Property-name hints apply right after "{" or "," (key-or-close), but + // also the moment the user starts typing a fresh '"' right after a + // value with no separating comma yet (comma-or-close) - they've clearly + // started a new key, the missing comma is a separate (already-flagged) + // syntax error, not a reason to withhold the hint. + const atKeyPosition = top.kind === 'object' + && (ctx.expect === 'key-or-close' || (ctx.expect === 'comma-or-close' && partial && partial.type === 'string')); + if (atKeyPosition) { + const props = (top.effective && top.effective.properties) || {}; + const required = (top.effective && top.effective.required) || []; + const prefix = partial ? text.slice(partial.start + 1, Math.min(offset, partial.end)) : ''; + const items = Object.keys(props) + .filter((k) => !top.keys.has(k)) + .filter((k) => k.toLowerCase().startsWith(prefix.toLowerCase())) + .sort((a, b) => { + if (required.includes(a) !== required.includes(b)) return required.includes(a) ? -1 : 1; + return a.localeCompare(b); + }) + .map((k) => ({ + label: k, + required: required.includes(k), + description: props[k].description || resolveSchema(props[k], rootSchema).description || resolveSchema(props[k], rootSchema).title || '', + insertText: JSON.stringify(k) + ': ', + })); + return { range: partial ? [partial.start, offset] : [offset, offset], items }; + } + + if (ctx.expect === 'value') { + let valueSchema = null; + if (top.kind === 'object' && ctx.pendingKey) valueSchema = (top.effective && top.effective.properties && top.effective.properties[ctx.pendingKey]) || null; + else if (top.kind === 'array') valueSchema = (top.effective && top.effective.items) || null; + else if (top.kind === 'root') valueSchema = top.rawSchema; + if (!valueSchema) return null; + const resolved = resolveSchema(valueSchema, rootSchema); + const isStringPartial = partial && partial.type === 'string'; + const isIdentPartial = partial && partial.type === 'ident'; + if (resolved.enum) { + const prefix = isStringPartial ? text.slice(partial.start + 1, Math.min(offset, partial.end)) : (isIdentPartial ? text.slice(partial.start, offset) : ''); + const items = resolved.enum + .filter((v) => String(v).toLowerCase().startsWith(prefix.toLowerCase())) + .map((v) => ({ label: String(v), description: resolved.description || '', insertText: JSON.stringify(v) })); + return { range: partial ? [partial.start, offset] : [offset, offset], items }; + } + if (resolved.type === 'boolean' && (isIdentPartial || !partial)) { + const prefix = isIdentPartial ? text.slice(partial.start, offset) : ''; + const items = ['true', 'false'].filter((v) => v.startsWith(prefix)).map((v) => ({ label: v, description: '', insertText: v })); + return { range: partial ? [partial.start, offset] : [offset, offset], items }; + } + return null; + } + return null; + } + + // Resolves what schema/path applies exactly at the cursor, for the + // read-only "field info" panel that updates as the caret moves. + function describeJSONCursorContext(ctx, rootSchema) { + const top = ctx.top; + if (ctx.expect === 'value') { + if (top.kind === 'object' && ctx.pendingKey) { + const raw = (top.effective && top.effective.properties && top.effective.properties[ctx.pendingKey]) || null; + return { path: top.path ? top.path + '.' + ctx.pendingKey : ctx.pendingKey, raw, schema: raw ? resolveSchema(raw, rootSchema) : null }; + } + if (top.kind === 'array') { + const raw = (top.effective && top.effective.items) || null; + return { path: top.path + '[' + (top.index || 0) + ']', raw, schema: raw ? resolveSchema(raw, rootSchema) : null }; + } + if (top.kind === 'root') return { path: '(root)', raw: top.rawSchema, schema: resolveSchema(top.rawSchema, rootSchema) }; + } + return { path: top.path || '(root)', raw: top.rawSchema, schema: top.effective }; + } + + function jsonLineColAt(text, offset) { + let line = 1; + let col = 1; + for (let i = 0; i < offset && i < text.length; i++) { + if (text[i] === '\n') { line++; col = 1; } else col++; + } + return { line, col }; + } + + function renderJSONHighlightHTML(text, tokens, errorRanges) { + let html = ''; + let last = 0; + const overlapsError = (s, e) => errorRanges.some((r) => s < r.end && e > r.start); + tokens.forEach((t) => { + if (t.start > last) html += escapeHtml(text.slice(last, t.start)); + let cls; + if (t.type === 'punct') cls = 'jt-punct'; + else if (t.type === 'string') cls = t.role === 'key' ? 'jt-key' : 'jt-string'; + else if (t.type === 'number') cls = 'jt-number'; + else if (t.type === 'boolean') cls = 'jt-boolean'; + else if (t.type === 'null') cls = 'jt-null'; + else cls = 'jt-plain'; + if (overlapsError(t.start, t.end)) cls += ' jt-error'; + html += '' + escapeHtml(text.slice(t.start, t.end)) + ''; + last = t.end; + }); + if (last < text.length) html += escapeHtml(text.slice(last)); + if (text.length === 0 || text.endsWith('\n')) html += '\n'; + return html; + } + + // Mirrors the textarea's text-affecting CSS onto a hidden, off-screen div + // so a marker span inserted at a given character offset reports the pixel + // position the caret would render at - the standard technique for + // positioning UI (here, the autocomplete popup) relative to caret in a + // plain