diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c82de11..fc57235 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,9 +27,22 @@ jobs: go-version-file: go.mod cache: true + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22.12.0" + cache: npm + cache-dependency-path: web/package-lock.json + - name: Run unit tests run: go test -tags=unit ./... + - name: Build UI, Go packages, and release binaries + run: make all + + - name: Verify Node-free fallback build + run: make build-go + - name: Run Ginkgo BDD specs run: make test-bdd diff --git a/.gitignore b/.gitignore index 5f54171..9b8eeeb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ coverage.out .idea/ .vscode/ .DS_Store +web/node_modules/ +web/dist/ +web/.vite/ diff --git a/Makefile b/Makefile index c908415..b9f5484 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,41 @@ GO ?= go BUF ?= buf +NPM ?= npm +UI_DIR ?= web +BIN_DIR ?= bin CONTROLLER_GEN ?= controller-gen COVERAGE_OUT ?= coverage.out -.PHONY: all build test test-bdd lint proto generate tools integration e2e fault perf upgrade coverage coverage-check csi-sanity phase0 +.PHONY: all binaries build build-go ui clean-assets test test-bdd lint proto generate tools integration e2e fault perf upgrade coverage coverage-check csi-sanity phase0 -all: build +# Release layout: build the embedded UI and all command binaries in bin/. +all: binaries -build: +binaries: build + mkdir -p $(BIN_DIR) + CGO_ENABLED=0 $(GO) build -trimpath -o $(BIN_DIR)/kouja-agent ./cmd/kouja-agent + CGO_ENABLED=0 $(GO) build -trimpath -o $(BIN_DIR)/kouja-csi-controller ./cmd/kouja-csi-controller + CGO_ENABLED=0 $(GO) build -trimpath -o $(BIN_DIR)/kouja-csi-node ./cmd/kouja-csi-node + install -m 0644 deploy/config.example.yaml $(BIN_DIR)/config.example.yaml + install -m 0755 deploy/kouja-init.sh $(BIN_DIR)/kouja-init.sh + +build: ui + $(GO) build ./... + +# Node-free build. Clean generated assets first so a previous full build does +# not accidentally embed the browser bundle. +build-go: clean-assets $(GO) build ./... +ui: clean-assets + cd $(UI_DIR) && $(NPM) ci + cd $(UI_DIR) && $(NPM) run build + test -f internal/adminui/assets/index.html + +clean-assets: + find internal/adminui/assets -mindepth 1 -maxdepth 1 \ + ! -name .gitkeep ! -name .gitignore -exec rm -rf {} + + test: $(GO) test -tags=unit ./... @@ -17,6 +43,7 @@ test-bdd: $(GO) test -v ./test/bdd lint: + bash -n deploy/kouja-init.sh deploy/install/install.sh $(GO) vet ./... golangci-lint run diff --git a/README.md b/README.md index 90b3239..6872e36 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ serialized per volume and per volume group. | Recovery | Startup and periodic reconciliation, drift reporting, and admin actions. | | Kubernetes | CSI controller and node plugin for provisioning, attach/detach, mount, expansion, and cleanup. | | Observability | `/healthz`, `/readyz`, Prometheus metrics, structured logs, and audit rows. | +| Local admin UI | Optional loopback-only configuration and status page; no authentication, disabled by default. | Not supported by v1: thin provisioning, snapshots, clones, replication, multi-initiator/RWX volumes, automatic high availability, or encryption of data @@ -120,7 +121,9 @@ Go 1.26 or newer is required. git clone https://github.com/voidstarit/kouja.git cd kouja -make build # compile all packages +make all # build the embedded UI, binaries, bin/kouja-init.sh, and bin/config.example.yaml (Node 22.12) +make build # build the Preact UI, then all Go packages (Node 22.12) +make build-go # Go-only build with the fallback admin page, no Node make test # unit tests make test-bdd # Ginkgo/Gomega BDD suite make coverage-check # enforce >= 80% coverage under ./internal @@ -165,6 +168,60 @@ See [Client SDK](docs/clients.md) for complete examples, request-ID propagation, and regeneration instructions. Error reasons and retryability are documented in [Client Errors and Retries](docs/client-errors.md). +## Local admin UI + +The optional admin UI is embedded in `kouja-agent` and is disabled by default. +Enable it only on a single-administrator host: + +```yaml +admin_ui: + enabled: true + address: 127.0.0.1:9444 + client_cert_dir: /etc/kouja/tls/clients +``` + +Restart the agent once, then open `http://127.0.0.1:9444`. The listener is +loopback-only and has **no authentication**. Any local process that can reach +it can rewrite the root-owned configuration and change live VG/class mappings; +this is a root-equivalent trust boundary. Do not proxy or port-forward it, and +do not use it on a host with untrusted local users or host-network containers. +The accepted deployment precondition is a single-administrator host with no +SSRF-capable local service. + +The UI shows per-volume state, serving status, sessions, observations, VGs, +iSCSI enablement/IQN/portals, the TLS server certificate’s DNS/IP subject +alternative names, client certificate files, and problem operations. It also +shows live iSCSI traffic from LIO configfs: cumulative read/write bytes, +commands, session RX/TX, sampled read/write/command rates, LUN status, and +login/session/abort/reset error counters. It also shows established client +IP/port connections, matched to the target and initiator where the session +endpoint is unambiguous. These counters are best-effort and never block the +status view. Each live iSCSI target has a **Disable** action: it force-removes +the live LIO target and drops active sessions while retaining the volume and +export definition in SQLite. The volume row then shows **Enable target** to +recreate the target from the stored definition and reinstall the same CHAP +credential. +It can edit the full YAML file with validation and atomic writes, and it can +register/deregister VGs and add/remove class mappings live through the same VG +logic as the mTLS admin API. It never restarts the service. A binding-socket +change sets `restart_required`; restart manually with +`systemctl restart kouja-agent`. Other startup-bound changes produce warnings. + +Client certificates are listed from `admin_ui.client_cert_dir` (default: +`/clients`). Only regular `.crt`, `.key`, and `.pem` files +directly inside that directory are downloadable; symlinks, the CA key, and the +server private key are never exposed. The public client CA +(`listen.tls.client_ca_file`) is available from +**Configuration → Client certificates → Download client CA** or directly at +`GET /api/ca`. The list includes client private keys, so keep that directory +root-owned at `0700` and treat downloads as sensitive. +`.bak` is the immediate pre-save copy; `.last-good` is the startup +configuration that last reached readiness. Live class changes may need to be +reapplied after a failed-start recovery. + +`make build` requires Node 22.12 and builds the embedded UI. `make build-go` +builds without Node and serves the fallback page from `internal/adminui`. + ## Install the agent ### 1. Prepare the host @@ -191,9 +248,30 @@ Make sure `/etc/tlshd.conf` includes the `.nvme` keyring: keyrings=.nvme ``` -### 2. Install TLS material +### 2. Initialize TLS and configuration + +The fastest path on a fresh host is the interactive initializer shipped with +the release binaries: + +```sh +sudo ./bin/kouja-init.sh +# or, from a checkout: +sudo ./deploy/kouja-init.sh +``` + +It requires `openssl` and walks through the configuration step by step, then: -The agent serves TLS 1.3 mTLS. Install the files before running the installer: +- generates a local test CA, server certificate, and `client-ca.crt`; +- generates an admin client certificate and a caller client certificate by + default, with optional controller and node client certificates; +- writes the certificate files under `/etc/kouja/tls` with private keys at + `0600` and the TLS directory at `0700`; +- validates the generated YAML with `kouja-agent --check-config`; +- backs up any existing config and installs `/etc/kouja/config.yaml` at `0600`. + +The generated CA is a convenience for a self-contained host. For production, +prefer your operator PKI and choose the “use existing certificate files” path, +or install the files manually: ```text /etc/kouja/tls/server.crt # server certificate @@ -201,14 +279,35 @@ The agent serves TLS 1.3 mTLS. Install the files before running the installer: /etc/kouja/tls/client-ca.crt # CA used to verify API client certificates ``` -The directory should be `0700` root-owned and the files `0600`. Client -certificate URI SANs determine roles: +The server certificate must include the DNS names and IP addresses clients use +to reach the agent. Client certificate URI SANs determine roles: - `spiffe://kouja/admin/` — administrator - `spiffe://kouja/caller/` — volume caller - `spiffe://kouja/controller/` — CSI controller - `spiffe://kouja/node/` — CSI node +You can validate any configuration, including one written by hand, without +starting the agent: + +```sh +bin/kouja-agent --config /etc/kouja/config.yaml --check-config +``` + +The generated CA certificate is public and can be copied to clients manually: + +```sh +sudo cat /etc/kouja/tls/client-ca.crt +# or +scp root@storage-host:/etc/kouja/tls/client-ca.crt . +``` + +When the admin UI is enabled, use **Configuration → Client certificates → +Download client CA**. The CA private key (`ca.key`) is never served by the UI. + +`make all` places the initializer, the release binaries, and +`bin/config.example.yaml` in `bin/`. + ### 3. Run the installer From the repository root: @@ -226,8 +325,9 @@ The installer: - disables the legacy `target.service`; - enables and starts `kouja-agent.service`. -The installer does **not** create certificates or register volume groups. Edit -the configuration and restart: +The installer itself does **not** create certificates or register volume +groups; `kouja-init.sh` is the separate interactive helper for that. If you +install first, run the initializer afterward and restart: ```sh sudoedit /etc/kouja/config.yaml diff --git a/cmd/kouja-agent/main.go b/cmd/kouja-agent/main.go index 22eb59c..ec4c39a 100644 --- a/cmd/kouja-agent/main.go +++ b/cmd/kouja-agent/main.go @@ -9,10 +9,12 @@ import ( "os" "os/signal" "path/filepath" + "sync" "sync/atomic" "syscall" "time" + "github.com/voidstarit/kouja/internal/adminui" "github.com/voidstarit/kouja/internal/agent" "github.com/voidstarit/kouja/internal/backup" "github.com/voidstarit/kouja/internal/config" @@ -40,12 +42,17 @@ func main() { func run() error { configPath := flag.String("config", "/etc/kouja/config.yaml", "path to the agent configuration") + checkConfig := flag.Bool("check-config", false, "validate the configuration and exit") flag.Parse() - cfg, err := config.Load(*configPath) + cfg, configData, err := config.LoadWithData(*configPath) if err != nil { return err } + if *checkConfig { + _, _ = fmt.Fprintln(os.Stdout, "configuration valid") + return nil + } logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) slog.SetDefault(logger) @@ -92,6 +99,9 @@ func run() error { if err := agent.InitializeVGs(context.Background(), store, lvmBackend, cfg.EffectiveVGs()); err != nil { return err } + vgClassGate := &sync.Mutex{} + classMap := agent.NewClassMap(cfg.Storage.Classes, *configPath) + vgManager := agent.NewVGManager(store, lvmBackend, classMap) stepTimeouts := make(map[operations.Action]time.Duration) for action, seconds := range cfg.Operations.StepTimeoutSecs { @@ -124,18 +134,35 @@ func run() error { Ready: ready.Load, Admission: admission, StorageClasses: cfg.Storage.Classes, + Classes: classMap, }) adminService := agent.NewAdminService(store, lvmBackend, agent.WithTargetExporters(map[string]target.Exporter{"iscsi": iscsi, "nvme-tcp": nvme}), agent.WithCredentialManager(credentials), agent.WithDriftRetrier(manager), agent.WithStorageClasses(cfg.Storage.Classes), + agent.WithClassRegistry(classMap), + agent.WithVGService(vgManager), + agent.WithVGClassGate(vgClassGate), ) server, err := agent.NewServer(cfg, volumeService, adminService, agent.WithReadiness(ready.Load), agent.WithMetrics(metrics)) if err != nil { return err } + metrics.Set("kouja_admin_ui_up", 0) + adminServer := startAdminServer(cfg, adminui.Options{ + ConfigPath: *configPath, Config: cfg, ConfigData: configData, + Store: store, Capacity: lvmBackend, VGs: vgManager, Classes: classMap, + Ready: ready.Load, HostUUID: hostUUID, AgentVersion: agentVersion, + APIVersion: apiVersion, StartedAt: time.Now(), Logger: logger, Metrics: metrics, + TargetMetrics: iscsi, DisableTarget: manager, EnableTarget: manager, + VGClassGate: vgClassGate, + }) + if adminServer != nil { + logger.Warn("local admin UI enabled without authentication; any local process is root-equivalent", + "address", cfg.AdminUI.Address) + } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -150,18 +177,40 @@ func run() error { reconciler.Run(ctx) }() ready.Store(true) + if adminServer != nil { + if err := adminServer.MarkRunning(); err != nil { + logger.Error("write last-good configuration", "error", err) + } + go func() { + if err := adminServer.Serve(); err != nil { + logger.Error("admin UI server stopped", "error", err) + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = adminServer.Shutdown(shutdownCtx) + cancel() + } + }() + } logger.Info("agent listening", "address", cfg.Listen.Address, "host_uuid", hostUUID) serveErr := make(chan error, 1) go func() { serveErr <- server.Serve() }() + shutdownAdmin := func() { + if adminServer != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = adminServer.Shutdown(shutdownCtx) + cancel() + } + } select { case <-ctx.Done(): + shutdownAdmin() server.GracefulStop() <-reconcileDone <-backupDone return nil case err := <-serveErr: ready.Store(false) + shutdownAdmin() server.Stop() <-reconcileDone <-backupDone @@ -172,6 +221,18 @@ func run() error { } } +func startAdminServer(cfg *config.Config, options adminui.Options) *adminui.Server { + if cfg == nil || !cfg.AdminUI.Enabled { + return nil + } + server, err := adminui.New(options) + if err != nil { + slog.Error("admin UI listener failed; continuing without it", "error", err) + return nil + } + return server +} + func acquireLock(path string) (*os.File, error) { file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec // path is the configured data-directory lock if err != nil { diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml new file mode 100644 index 0000000..c1964e9 --- /dev/null +++ b/deploy/config.example.yaml @@ -0,0 +1,81 @@ +# Example kouja-agent configuration. +# Install with: install -D -m 0600 deploy/config.example.yaml /etc/kouja/config.yaml +# The shipped systemd unit reads /etc/kouja/config.yaml by default. + +host_id_file: /var/lib/kouja/host-id +data_dir: /var/lib/kouja +secret_key_file: /var/lib/kouja/secret.key +emergency_log: /var/lib/kouja/emergency.log + +listen: + address: 0.0.0.0:9443 + tls: + cert_file: /etc/kouja/tls/server.crt + key_file: /etc/kouja/tls/server.key + client_ca_file: /etc/kouja/tls/client-ca.crt + min_version: TLS1.3 + +# Optional local admin UI. It is disabled by default, binds only to loopback, +# and has no authentication. Any local process that can reach it is +# root-equivalent. Enable only on a single-administrator host and never proxy +# or port-forward it. +admin_ui: + enabled: false + address: 127.0.0.1:9444 + # Directory containing client certificates/keys exposed for download by the + # local admin UI. Only this directory is served, never the CA or server keys. + client_cert_dir: /etc/kouja/tls/clients + +storage: + headroom_percent: 5 + headroom_min_bytes: 1073741824 + wipe_timeout_seconds: 1800 + # VGs to seed on the first start. After first start, use the mTLS admin API + # or the local admin UI to register or deregister VGs. + vgs: [] + # Storage-class mappings, e.g. {fast: vg-nvme, hdd: vg-hdd}. + classes: {} + +targets: + iscsi: + enabled: true + iqn_prefix: iqn.2026-11.io.kouja + portals: + - address: 0.0.0.0 + port: 3260 + nvme_tcp: + enabled: false + nqn_prefix: nqn.2026-11.io.kouja + portals: + - address: 0.0.0.0 + port: 4420 + tls: + enabled: true + keyring: .nvme + tlshd_service: tlshd + +operations: + max_attempts: 3 + total_timeout_seconds: 300 + step_timeout_seconds: + create: 120 + export: 60 + delete: 120 + resize: 120 + +admission: + max_concurrent_operations: 64 + per_caller_operations: 10 + capacity_requests_per_second: 10 + +state: + backup_dir: /var/lib/kouja/backups + # Set to an off-host writable mount for backups, or leave empty to disable. + backup_target: "" + backup_interval_seconds: 900 + backup_retention_short: 96 + backup_retention_daily: 7 + backup_retention_weekly: 4 + integrity_check_interval_seconds: 86400 + reconcile_interval_seconds: 60 + audit_retention_days: 90 diff --git a/deploy/install/install.sh b/deploy/install/install.sh index 6a55aba..75dce56 100755 --- a/deploy/install/install.sh +++ b/deploy/install/install.sh @@ -32,6 +32,10 @@ listen: key_file: /etc/kouja/tls/server.key client_ca_file: /etc/kouja/tls/client-ca.crt min_version: TLS1.3 +admin_ui: + enabled: false + address: 127.0.0.1:9444 + client_cert_dir: /etc/kouja/tls/clients storage: headroom_percent: 5 headroom_min_bytes: 1073741824 @@ -78,6 +82,10 @@ systemctl enable --now kouja-agent.service cat <<'EOF' kouja is installed. Firewall ports: 9443/tcp control, 3260/tcp iSCSI, 4420/tcp NVMe-oF. +The optional admin_ui listener is disabled. If enabled, it is loopback-only +and has no authentication; any local process that can reach it is +root-equivalent. Do not proxy or port-forward it and use it only on a +single-administrator host. Uninstall: systemctl disable --now kouja-agent; rm -f /etc/systemd/system/kouja-agent.service /etc/tmpfiles.d/kouja-agent.conf /usr/local/bin/kouja-agent. Data volumes are not deleted by uninstall. EOF diff --git a/deploy/kouja-init.sh b/deploy/kouja-init.sh new file mode 100755 index 0000000..d9368c3 --- /dev/null +++ b/deploy/kouja-init.sh @@ -0,0 +1,489 @@ +#!/usr/bin/env bash +# Interactive kouja TLS + configuration initializer. +# +# This helper is intended for a fresh single-host installation. It can: +# * generate a local test CA, server certificate, and client certificates; +# * collect the common agent settings with prompts and sensible defaults; +# * validate the generated YAML with `kouja-agent --check-config`; +# * install the configuration and TLS files with restrictive permissions. +# +# For production, prefer certificates from your existing PKI. The local CA +# generated here is a convenience for self-contained hosts and testing. +set -euo pipefail +umask 077 + +GO="${GO:-go}" +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_PATH="${KOUJA_CONFIG_PATH:-/etc/kouja/config.yaml}" +TLS_DIR="${KOUJA_TLS_DIR:-$(dirname "$CONFIG_PATH")/tls}" +NONINTERACTIVE="${KOUJA_INIT_NONINTERACTIVE:-0}" + +if [[ -t 1 ]]; then + BOLD=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[31m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m'; RESET=$'\033[0m' +else + BOLD=""; DIM=""; RED=""; GREEN=""; YELLOW=""; RESET="" +fi + +say() { printf '%s\n' "$*"; } +warn() { printf '%sWARN:%s %s\n' "$YELLOW" "$RESET" "$*" >&2; } +die() { printf '%sERROR:%s %s\n' "$RED" "$RESET" "$*" >&2; exit 1; } +step() { printf '\n%s==> %s%s\n' "$BOLD" "$*" "$RESET"; } + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +ask() { + local prompt="$1" default="${2:-}" reply + if [[ "$NONINTERACTIVE" == "1" ]]; then + printf '%s [%s]\n' "$prompt" "$default" >&2 + printf '%s' "$default" + return 0 + fi + if [[ -n "$default" ]]; then + printf '%s [%s]: ' "$prompt" "$default" >&2 + else + printf '%s: ' "$prompt" >&2 + fi + read -r reply || reply="" + printf '%s' "${reply:-$default}" +} + +ask_yes_no() { + local prompt="$1" default="${2:-n}" reply + if [[ "$NONINTERACTIVE" == "1" ]]; then + printf '%s [%s]\n' "$prompt" "$default" >&2 + [[ "$default" == "y" || "$default" == "Y" ]] + return $? + fi + printf '%s [%s]: ' "$prompt" "$default" >&2 + read -r reply || reply="" + reply="${reply:-$default}" + case "$reply" in + y|Y|yes|YES) return 0 ;; + n|N|no|NO) return 1 ;; + *) return 1 ;; + esac +} + +yaml_quote() { + local value="$1" + value="${value//\'/\'\'}" + printf "'%s'" "$value" +} + +is_loopback_address() { + [[ "$1" =~ ^127\.0\.0\.1:[0-9]+$ || "$1" =~ ^\[::1\]:[0-9]+$ ]] +} + +is_ipv4() { + [[ "$1" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] +} + +server_san_entries() { + local input="$1" item output="" + IFS=',' read -r -a parts <<< "$input" + for item in "${parts[@]}"; do + item="$(trim "$item")" + [[ -z "$item" ]] && continue + if is_ipv4 "$item" || [[ "$item" == *:* ]]; then + output+="IP:${item}," + else + output+="DNS:${item}," + fi + done + printf '%s' "${output%,}" +} + +die_if_missing_file() { + local path="$1" label="$2" + [[ -n "$path" && -r "$path" ]] || die "$label is required and must be readable: $path" +} + +gen_ca() { + say "Generating local CA..." + openssl genrsa -out "$CA_KEY" 4096 >/dev/null 2>&1 + openssl req -x509 -new -nodes -key "$CA_KEY" -sha256 -days 3650 \ + -subj "/CN=kouja-local-ca" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" \ + -out "$CA_CERT" >/dev/null 2>&1 + chmod 0600 "$CA_KEY" + chmod 0644 "$CA_CERT" +} + +gen_server() { + local san + san="$(server_san_entries "$SERVER_SANS")" + [[ -n "$san" ]] || die "at least one server DNS name or IP SAN is required" + say "Generating server certificate for SANs: $san" + openssl genrsa -out "$SERVER_KEY" 3072 >/dev/null 2>&1 + openssl req -new -key "$SERVER_KEY" -subj "/CN=kouja-agent" \ + -out "$WORKDIR/server.csr" >/dev/null 2>&1 + cat >"$WORKDIR/server.ext" </dev/null 2>&1 + chmod 0600 "$SERVER_KEY" + chmod 0644 "$SERVER_CERT" +} + +gen_client() { + local role="$1" name="$2" + local cert="$TLS_DIR/clients/${role}-${name}.crt" + local key="$TLS_DIR/clients/${role}-${name}.key" + local csr="$WORKDIR/${role}-${name}.csr" + local ext="$WORKDIR/${role}-${name}.ext" + if [[ -e "$cert" || -e "$key" ]]; then + if ! ask_yes_no "Overwrite existing ${role} client certificate for ${name}?" n; then + say "Keeping existing $cert" + return 0 + fi + fi + say "Generating ${role} client certificate: spiffe://kouja/${role}/${name}" + openssl genrsa -out "$key" 3072 >/dev/null 2>&1 + openssl req -new -key "$key" -subj "/CN=kouja-${role}-${name}" -out "$csr" >/dev/null 2>&1 + cat >"$ext" </dev/null 2>&1 + chmod 0600 "$key" + chmod 0644 "$cert" + if [[ "$role" == "admin" ]]; then + ADMIN_CERT="$cert" + ADMIN_KEY="$key" + fi + CLIENT_CERTS+=("$cert") +} + +say "${BOLD}kouja local setup${RESET}" +say "This helper writes a configuration file and, optionally, a local test PKI." +warn "For production, use certificates from your operator PKI. The generated CA is for convenience/testing." +if [[ "$EUID" -ne 0 && "$CONFIG_PATH" == /etc/* ]]; then + warn "You are not root; writing $CONFIG_PATH may fail. Re-run with sudo or choose a writable path." +fi + +step "1/7 Configuration paths" +CONFIG_PATH="$(ask "Configuration file" "$CONFIG_PATH")" +if [[ -e "$CONFIG_PATH" ]]; then + if ! ask_yes_no "Configuration exists. Overwrite it (a timestamped .bak is kept)?" n; then + die "aborted; existing configuration left unchanged" + fi +fi +TLS_DIR="$(ask "TLS directory" "$TLS_DIR")" +HOST_ID_FILE="$(ask "Host ID file" "/var/lib/kouja/host-id")" +DATA_DIR="$(ask "Data directory" "/var/lib/kouja")" +SECRET_KEY_FILE="$(ask "Credential secret key file" "/var/lib/kouja/secret.key")" +EMERGENCY_LOG="$(ask "Emergency log file" "/var/lib/kouja/emergency.log")" + +step "2/7 Control and admin listeners" +LISTEN_ADDRESS="$(ask "mTLS listener address" "0.0.0.0:9443")" +ADMIN_ENABLED=0 +ADMIN_ADDRESS="127.0.0.1:9444" +if ask_yes_no "Enable the local admin UI? (loopback only, no authentication)" n; then + ADMIN_ENABLED=1 + ADMIN_ADDRESS="$(ask "Admin UI address" "$ADMIN_ADDRESS")" + while ! is_loopback_address "$ADMIN_ADDRESS"; do + warn "Admin address must be exactly 127.0.0.1: or [::1]:" + ADMIN_ADDRESS="$(ask "Admin UI address" "$ADMIN_ADDRESS")" + done +fi + +step "3/7 Storage" +VGS_RAW="$(ask "Volume groups to seed on first start (comma-separated, blank for none)" "")" +CLASSES_RAW="$(ask "Storage-class mappings (comma-separated class=vg, blank for none)" "")" + +step "4/7 iSCSI target" +ISCSI_ENABLED=0 +ISCSI_PORTAL="0.0.0.0:3260" +IQN_PREFIX="iqn.2026-11.io.kouja" +if ask_yes_no "Enable the iSCSI target?" y; then + ISCSI_ENABLED=1 + IQN_PREFIX="$(ask "iSCSI IQN prefix" "$IQN_PREFIX")" + ISCSI_PORTAL="$(ask "iSCSI portal address:port" "$ISCSI_PORTAL")" +fi +ISCSI_ADDR="${ISCSI_PORTAL%:*}" +ISCSI_PORT="${ISCSI_PORTAL##*:}" + +step "5/7 NVMe/TCP target" +NVME_ENABLED=0 +NVME_PORTAL="0.0.0.0:4420" +NQN_PREFIX="nqn.2026-11.io.kouja" +NVME_KEYRING=".nvme" +NVME_TLSHD="tlshd" +if ask_yes_no "Enable the NVMe/TCP target? (requires nvmet, nvmet_tcp, and tlshd)" n; then + NVME_ENABLED=1 + NQN_PREFIX="$(ask "NVMe NQN prefix" "$NQN_PREFIX")" + NVME_PORTAL="$(ask "NVMe/TCP portal address:port" "$NVME_PORTAL")" + NVME_KEYRING="$(ask "NVMe/TCP TLS keyring" "$NVME_KEYRING")" + NVME_TLSHD="$(ask "NVMe/TCP tlshd service name" "$NVME_TLSHD")" +fi +NVME_ADDR="${NVME_PORTAL%:*}" +NVME_PORT="${NVME_PORTAL##*:}" + +step "6/7 Backup" +BACKUP_DIR="$(ask "Local backup directory" "/var/lib/kouja/backups")" +BACKUP_TARGET="$(ask "Off-host backup target (blank to disable backups)" "")" + +step "7/7 TLS" +SERVER_CERT="$TLS_DIR/server.crt" +SERVER_KEY="$TLS_DIR/server.key" +CLIENT_CA="$TLS_DIR/client-ca.crt" +CA_CERT="$TLS_DIR/ca.crt" +CA_KEY="$TLS_DIR/ca.key" +CLIENT_CERTS=() + +if ask_yes_no "Generate a local test CA and certificates?" y; then + command -v openssl >/dev/null 2>&1 || die "openssl is required to generate certificates" + WORKDIR="$(mktemp -d)" + trap 'rm -rf "$WORKDIR"' EXIT + mkdir -p "$TLS_DIR/clients" + chmod 0700 "$TLS_DIR" "$TLS_DIR/clients" + + if [[ -s "$CA_CERT" && -s "$CA_KEY" && -s "$SERVER_CERT" && -s "$SERVER_KEY" ]]; then + if ask_yes_no "Reuse the existing local CA and server certificate?" y; then + say "Reusing existing CA and server certificate." + else + if ! ask_yes_no "Back up and replace the existing TLS files?" n; then + die "aborted; existing TLS files left unchanged" + fi + BACKUP_TLS="$TLS_DIR.bak.$(date +%Y%m%d%H%M%S)" + cp -a "$TLS_DIR" "$BACKUP_TLS" + say "TLS backup: $BACKUP_TLS" + gen_ca + SERVER_SANS="$(ask "Server DNS names/IPs (comma-separated)" "$(hostname -f 2>/dev/null || hostname),localhost,127.0.0.1")" + gen_server + fi + elif [[ -e "$CA_CERT" || -e "$CA_KEY" || -e "$SERVER_CERT" || -e "$SERVER_KEY" ]]; then + if ! ask_yes_no "Existing TLS files are incomplete. Back up and replace them?" n; then + die "aborted; existing TLS files left unchanged" + fi + BACKUP_TLS="$TLS_DIR.bak.$(date +%Y%m%d%H%M%S)" + cp -a "$TLS_DIR" "$BACKUP_TLS" + say "TLS backup: $BACKUP_TLS" + gen_ca + DEFAULT_SANS="$(hostname -f 2>/dev/null || hostname),localhost,127.0.0.1" + PRIMARY_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" + if [[ -n "$PRIMARY_IP" ]]; then + DEFAULT_SANS="${DEFAULT_SANS},${PRIMARY_IP}" + fi + SERVER_SANS="$(ask "Server DNS names/IPs (comma-separated)" "$DEFAULT_SANS")" + gen_server + else + gen_ca + DEFAULT_SANS="$(hostname -f 2>/dev/null || hostname),localhost,127.0.0.1" + PRIMARY_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" + if [[ -n "$PRIMARY_IP" ]]; then + DEFAULT_SANS="${DEFAULT_SANS},${PRIMARY_IP}" + fi + SERVER_SANS="$(ask "Server DNS names/IPs (comma-separated)" "$DEFAULT_SANS")" + gen_server + fi + cp -f "$CA_CERT" "$CLIENT_CA" + chmod 0644 "$CLIENT_CA" + + if ask_yes_no "Generate an admin client certificate for the mTLS API?" y; then + ADMIN_NAME="$(ask "Admin client name" "admin")" + gen_client admin "$ADMIN_NAME" + fi + if ask_yes_no "Generate a caller client certificate for volume operations?" y; then + CALLER_NAME="$(ask "Caller client name" "caller")" + gen_client caller "$CALLER_NAME" + fi + if ask_yes_no "Generate a controller client certificate for the CSI controller?" n; then + CONTROLLER_NAME="$(ask "Controller name" "controller")" + gen_client controller "$CONTROLLER_NAME" + fi + if ask_yes_no "Generate a node client certificate for the CSI node?" n; then + NODE_NAME="$(ask "Node name" "node")" + gen_client node "$NODE_NAME" + fi +else + say "Using existing certificate files." + SERVER_CERT="$(ask "Server certificate" "$SERVER_CERT")" + SERVER_KEY="$(ask "Server private key" "$SERVER_KEY")" + CLIENT_CA="$(ask "Client CA certificate" "$CLIENT_CA")" + die_if_missing_file "$SERVER_CERT" "Server certificate" + die_if_missing_file "$SERVER_KEY" "Server private key" + die_if_missing_file "$CLIENT_CA" "Client CA certificate" +fi + +if [[ ${#CLIENT_CERTS[@]} -gt 0 ]]; then + openssl verify -CAfile "$CA_CERT" "${CLIENT_CERTS[@]}" >/dev/null +fi +if [[ -n "${SERVER_SANS:-}" && -r "$CA_CERT" && -r "$SERVER_CERT" ]]; then + openssl verify -CAfile "$CA_CERT" "$SERVER_CERT" >/dev/null +fi + +step "Writing and validating configuration" +WORKDIR="${WORKDIR:-$(mktemp -d)}" +trap 'rm -rf "$WORKDIR"' EXIT +TMP_CONFIG="$WORKDIR/config.yaml" + +q() { yaml_quote "$1"; } + +{ + echo "# Generated by kouja-init.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "host_id_file: $(q "$HOST_ID_FILE")" + echo "data_dir: $(q "$DATA_DIR")" + echo "secret_key_file: $(q "$SECRET_KEY_FILE")" + echo "emergency_log: $(q "$EMERGENCY_LOG")" + echo "listen:" + echo " address: $(q "$LISTEN_ADDRESS")" + echo " tls:" + echo " cert_file: $(q "$SERVER_CERT")" + echo " key_file: $(q "$SERVER_KEY")" + echo " client_ca_file: $(q "$CLIENT_CA")" + echo " min_version: TLS1.3" + echo "admin_ui:" + if [[ "$ADMIN_ENABLED" == "1" ]]; then + echo " enabled: true" + echo " address: $(q "$ADMIN_ADDRESS")" + else + echo " enabled: false" + echo " address: 127.0.0.1:9444" + fi + echo " client_cert_dir: $(q "$TLS_DIR/clients")" + echo "storage:" + echo " headroom_percent: 5" + echo " headroom_min_bytes: 1073741824" + echo " wipe_timeout_seconds: 1800" + if [[ -n "$(trim "$VGS_RAW")" ]]; then + echo " vgs:" + IFS=',' read -r -a vg_items <<< "$VGS_RAW" + for item in "${vg_items[@]}"; do + item="$(trim "$item")" + [[ -z "$item" ]] && continue + echo " - $(q "$item")" + done + else + echo " vgs: []" + fi + if [[ -n "$(trim "$CLASSES_RAW")" ]]; then + echo " classes:" + IFS=',' read -r -a class_items <<< "$CLASSES_RAW" + for item in "${class_items[@]}"; do + item="$(trim "$item")" + [[ -z "$item" ]] && continue + [[ "$item" == *=* ]] || die "class mapping must be class=vg: $item" + class="${item%%=*}" + vg="${item#*=}" + echo " $(q "$(trim "$class")"): $(q "$(trim "$vg")")" + done + else + echo " classes: {}" + fi + echo "targets:" + echo " iscsi:" + if [[ "$ISCSI_ENABLED" == "1" ]]; then + echo " enabled: true" + else + echo " enabled: false" + fi + echo " iqn_prefix: $(q "$IQN_PREFIX")" + echo " portals:" + echo " - address: $(q "$ISCSI_ADDR")" + echo " port: ${ISCSI_PORT}" + echo " nvme_tcp:" + if [[ "$NVME_ENABLED" == "1" ]]; then + echo " enabled: true" + else + echo " enabled: false" + fi + echo " nqn_prefix: $(q "$NQN_PREFIX")" + echo " portals:" + echo " - address: $(q "$NVME_ADDR")" + echo " port: ${NVME_PORT}" + echo " tls:" + echo " enabled: true" + echo " keyring: $(q "$NVME_KEYRING")" + echo " tlshd_service: $(q "$NVME_TLSHD")" + echo "operations:" + echo " max_attempts: 3" + echo " total_timeout_seconds: 300" + echo " step_timeout_seconds:" + echo " create: 120" + echo " export: 60" + echo " delete: 120" + echo " resize: 120" + echo "admission:" + echo " max_concurrent_operations: 64" + echo " per_caller_operations: 10" + echo " capacity_requests_per_second: 10" + echo "state:" + echo " backup_dir: $(q "$BACKUP_DIR")" + echo " backup_target: $(q "$BACKUP_TARGET")" + echo " backup_interval_seconds: 900" + echo " backup_retention_short: 96" + echo " backup_retention_daily: 7" + echo " backup_retention_weekly: 4" + echo " integrity_check_interval_seconds: 86400" + echo " reconcile_interval_seconds: 60" + echo " audit_retention_days: 90" +} >"$TMP_CONFIG" + +AGENT_BIN="${KOUJA_AGENT_BIN:-$SCRIPT_DIR/kouja-agent}" +if [[ ! -x "$AGENT_BIN" && -x "$SCRIPT_DIR/../bin/kouja-agent" ]]; then + AGENT_BIN="$SCRIPT_DIR/../bin/kouja-agent" +fi +if [[ -x "$AGENT_BIN" ]]; then + "$AGENT_BIN" --check-config --config "$TMP_CONFIG" +elif command -v "$GO" >/dev/null 2>&1 && [[ -f "$SCRIPT_DIR/../go.mod" ]]; then + (cd "$SCRIPT_DIR/.." && "$GO" run ./cmd/kouja-agent --check-config --config "$TMP_CONFIG") +else + warn "kouja-agent binary not found; skipped YAML validation. Build with 'make all' or set KOUJA_AGENT_BIN." +fi + +if [[ -e "$CONFIG_PATH" ]]; then + BACKUP_CONFIG="${CONFIG_PATH}.bak.$(date +%Y%m%d%H%M%S)" + cp -a "$CONFIG_PATH" "$BACKUP_CONFIG" + say "Configuration backup: $BACKUP_CONFIG" +fi +install -D -m 0600 "$TMP_CONFIG" "$CONFIG_PATH" + +say "" +say "${GREEN}Setup complete.${RESET}" +say " Configuration: $CONFIG_PATH" +say " Server cert: $SERVER_CERT" +say " Server key: $SERVER_KEY" +say " Client CA: $CLIENT_CA" +if [[ ${#CLIENT_CERTS[@]} -gt 0 ]]; then + say " Client certs:" + for cert in "${CLIENT_CERTS[@]}"; do + say " $cert" + done +fi +if [[ "$ADMIN_ENABLED" == "1" ]]; then + say " Admin UI: http://$ADMIN_ADDRESS" + warn "The admin UI has no authentication; keep it loopback-only." +fi +HOST_FOR_CLIENT="${LISTEN_ADDRESS%:*}" +if [[ "$HOST_FOR_CLIENT" == "0.0.0.0" || "$HOST_FOR_CLIENT" == "::" ]]; then + HOST_FOR_CLIENT="127.0.0.1" +fi +say "" +say "Next steps:" +say " 1. Review $CONFIG_PATH" +say " 2. Restart the agent: systemctl restart kouja-agent" +if [[ -n "${ADMIN_CERT:-}" ]]; then + say " 3. Test mTLS with the generated admin certificate:" + say " curl --cacert $(q "$CLIENT_CA") --cert $(q "$ADMIN_CERT") \\" + say " --key $(q "$ADMIN_KEY") https://${HOST_FOR_CLIENT}:9443/v1/admin/vgs" +else + say " 3. Test mTLS with your existing client certificate." +fi diff --git a/docs/designs/admin-ui.md b/docs/designs/admin-ui.md new file mode 100644 index 0000000..d44a2a7 --- /dev/null +++ b/docs/designs/admin-ui.md @@ -0,0 +1,2557 @@ +# kouja Local Admin UI + +* **Author:** kouja maintainers +* **Status:** Proposed +* **Date:** 2026-11-07 + +-------------------------------------------------------------------------------- + +## Overview + +`kouja-agent` gets a second HTTP listener that serves a local administration UI. +The listener is separate from the existing mTLS gRPC/HTTP API, binds to a +loopback address, and carries no application-level authentication. It is +disabled by default and enabled explicitly by the administrator. The UI is +written with Preact and TypeScript, built with Vite, and compiled into the +agent binary with `go:embed`. The production binary needs no separate static +server, CDN, or runtime asset directory; Node.js is required only to build the +full UI. + +The UI covers two jobs: + +1. **Configuration.** Show the effective running configuration and the raw + YAML file, let the administrator edit and save the file, and show whether + the disk configuration differs from the effective running configuration. + The UI never restarts the service. It reports that a restart is required + only when a binding socket changes: the mTLS listener address, the admin + listener enabled flag or (when enabled in either the running or candidate + configuration) its address, or a target portal address/port or protocol + enable flag on an enabled target. +2. **Status and live storage administration.** Show agent readiness, host + identity, version and uptime, volume groups, per-volume state, serving + status, active sessions, session state, drift reasons, observations, and + problem operations. Show live iSCSI read/write traffic, command counts, + per-initiator session traffic, client IP/port connections, and + target/LUN error and reset counters. + Provide live volume-group registration and deregistration through the + shared VG logic used by the existing admin service, and live storage-class + mapping changes through a new local class registry. These actions do not + restart the service. + +The existing mTLS API remains the surface for volume lifecycle operations, +credential handling, wipe, import, drift resolution, and ownership transfer. +The local UI does not create, export, resize, or delete volumes. + +The listener is a network boundary, not a user boundary. Any local process that +can reach the loopback port can read the configuration, see volume ownership and +initiators, rewrite the root-owned configuration, register or deregister volume +groups, and change class mappings. The accepted deployment precondition is a +single-administrator host with no untrusted local users, no host-network +containers, and no SSRF-capable local service. The installer and README must +state this precondition. + +## The Problem + +An administrator currently needs several unrelated tools to answer basic +questions about one storage host: + +* `sudoedit /etc/kouja/config.yaml` and `systemctl restart kouja-agent` to + change configuration. +* `systemctl status kouja-agent` and `journalctl -u kouja-agent` to see + whether the process is alive and why it failed. +* `curl` with a client certificate to call `/v1/info`, `/v1/capacity`, + `/v1/volumes`, or the admin VG endpoints. +* Direct SQLite queries, or an understanding of the state machine, to find + volumes in `drift`, failed operations, or active sessions. + +The mTLS API is designed for callers, not for a person at the console. It +requires certificate material, produces JSON, and separates configuration from +observation. There is no single local view that answers "is this host healthy, +what capacity does it have, which volumes are not in a normal state, what is +running, and what configuration is actually loaded?" + +A local UI reduces that work. It also makes configuration errors visible before +a restart: the administrator can validate the file, see the running +configuration alongside the file on disk, and keep the old text if validation +fails. Volume groups and class mappings are common administrative changes. VG +registration/deregistration reuses the existing admin service; class mappings +use a new live registry. Neither restarts the whole storage service. + +## Goals + +* Serve one embedded UI from `kouja-agent` on a separate loopback-only HTTP + listener. The listener is disabled by default, enabled explicitly, and + must never bind to a non-loopback address. An admin listener bind or serve + failure is logged and does not stop the mTLS API or the storage data path. +* Show the effective running configuration and the raw YAML file on disk. + Report `config_changed` when the parsed disk file differs from the + effective running configuration. Report `restart_required` only when a + binding socket + field changes. Never restart the service from the UI. +* Validate a proposed configuration with the existing `config.Parse` path + before writing it. Reject invalid YAML, unknown fields, relative paths, and + invalid values without changing the file. Missing runtime files such as TLS + material are preflight warnings, not parse errors. +* Write an accepted configuration atomically with mode `0600`, keep the + previous disk file as `.bak`, and keep the startup configuration that + reached readiness as `.last-good` when the admin UI is enabled. Saves and + live class writes do not refresh `.last-good`; it is the recovery point for + the configuration that last reached readiness. +* Register and deregister volume groups live through the existing admin + service logic. Add and remove storage-class mappings live. These actions + persist across restarts and do not restart the service. +* Show agent readiness, version, host UUID, supported protocols, uptime, the + configuration path, and the live configuration state. +* Show every registered volume group and every configured-but-unregistered + volume group, with storage class, UUID, state, total, free, allocatable, + extent, and maximum-volume capacity. +* Show each volume with owner, volume ID, resource UUID, LV name, VG and + class, requested and actual size, state, drift reasons, serving status, + export protocol and initiator, target/namespace/ACL presence, active + sessions (or unknown), session state, ACL/observed initiator, observation + age, and last update time. +* Show iSCSI LUN commands and coarse read/written totals, per-initiator + session bytes and PDU counts, target login/logout/session errors, LUN + aborts/resets, derived read/write/command rates, and established client + IP/port connections. Metrics are read from LIO configfs plus + `/proc/net/tcp(6)` and are best-effort; metrics unavailability never fails + the status response. +* Allow an operator to disable a live iSCSI target from the target table. + Disabling force-unexports the target, drops active sessions, and leaves the + volume and export definition in SQLite in the existing `available`/ + `unexported` state. It does not delete the volume or target definition. + A disabled iSCSI export remains visible in the volume table with an + **Enable target** action that recreates the target from the stored + definition with the same CHAP credential. +* Show up to 256 volumes and up to 64 problem operations. When the result is + truncated, show the total counts and a visible truncation notice. +* Show running, failed, and `needs_attention` operations with action, owner, + volume, resource UUID, attempt count, deadline, and last error code. +* Keep the existing mTLS API, state model, operation semantics, and volume + data path unchanged. No schema migration is added by this feature. +* Keep the agent a single runtime binary. `make build` builds the full Preact + UI and embeds it. `make build-go` builds the Go binary with the fallback + page and does not require Node.js. The runtime needs neither Node.js nor a + static asset directory. +* Reject non-loopback listener addresses at configuration validation. Set + Host, Origin, `Sec-Fetch-Site`, and content-type checks on the admin HTTP + server, emit no CORS headers, and never return secret values. + +## Non-Goals + +* Authentication, authorization, user accounts, roles, login pages, or + sessions on the admin listener. The listener is loopback-only. +* TLS on the admin listener. Traffic stays inside the host. +* Remote access from another machine. The listener must not bind to a public + or private network address or be exposed through a proxy or port forward. +* Restarting, stopping, or supervising the service from the UI. The UI only + reports that a binding socket changed; the operator restarts the service + with `systemctl` or the environment's supervisor. +* Automatic restart, process re-exec, manual-exit status 75, or restart + tracking/audit. The UI does not perform or observe restarts. +* Volume lifecycle operations, credential display or rotation, wipe, import, + drift resolution, or ownership transfer through the UI. Those remain on the + mTLS API. +* Live in-place reload of arbitrary configuration fields. The UI applies + volume-group and class changes through the admin service; other fields are + written to the file and take effect according to their owning subsystem or + the operator's restart decision. +* A structured form editor for every configuration field. The configuration + tab edits raw YAML. +* Fleet-wide or multi-host views. One UI serves one host. +* A committed frontend bundle. Vite output is generated at build time and + ignored by Git. +* A browser-side router, a state-management library, or an external component + library. The UI uses Preact and Preact hooks only. +* Log streaming, metrics graphs, backup management, or a full audit browser. + The current version shows cumulative counters and simple sampled rates, + not historical graphs. +* NVMe/TCP traffic counters. nvmet reports session state unknown on the + supported kernel; iSCSI traffic counters are included. +* New tables or columns for UI state. Status reads use the existing tables. +* Automatic removal of a VG when its configuration entry is removed. Removal + is an explicit live admin action. + +## Open Questions + +None. The restart policy is fixed: the UI never restarts the service; only +binding socket changes set `restart_required`. NVMe/TCP traffic counters remain +deferred because nvmet does not expose the equivalent per-session data. + +## Terminology + +* **Startup configuration:** The parsed `config.Config` loaded at process + start from `--config`. +* **Effective running configuration:** The startup configuration with + `Storage.Classes` overlaid by the live `ClassRegistry` snapshot. Services + and `running_yaml` use this view. +* **Disk configuration:** The YAML file at `--config`, parsed on demand. +* **Config changed:** `disk_valid` is false, or the parsed disk configuration + differs from the effective running configuration. List order that is + semantically meaningless (for example `storage.vgs`) is normalized; target + portal order is meaningful and is not normalized. +* **Binding socket change:** A change to `listen.address`, + `admin_ui.enabled`, an `admin_ui.address` change where the admin listener + is enabled in the running or candidate configuration, a protocol enable + flag, or a portal address/port change where the target is enabled in the + running or candidate configuration. A listener disabled in both is + config-only. These are the only fields that set `restart_required`; + `restart_required` is not a general “any startup-bound change” flag. + Non-socket startup-bound changes produce warnings only. +* **Drift:** A mismatch between stored intent and the observed LV or target + state. The reconciler records drift; the agent does not repair it + automatically. +* **Observation:** The reconciler's last snapshot of LV presence, size, and + device path. It can be up to one reconcile interval old. +* **Export observation:** The reconciler's last snapshot of target, + namespace, ACL, session state, and session count. +* **Serving:** The volume and export are stored as `exported`, the LV is + present, the target and ACL are present, the namespace is present for + `nvme-tcp`, and the observations are fresh. +* **Storage class label:** `storage_class` is the current class-to-VG mapping + label for a volume's VG. A live class remap relabels existing volumes in + status and mTLS responses; it does not change the volume's VG or data + placement. +* **Capacity admission:** The mTLS `GetCapacity` path calls + `Admission.AcquireCapacity`, a one-token burst rate limiter. The local + status path does not call it because it performs no allocation. +* **mTLS admin API:** The existing authenticated `AdminService` on the mTLS + listener. It is distinct from the local admin UI. + +## Critical User Journeys (CUJs) + +### CUJ 1: Administrator checks host status + +* **Given**: `kouja-agent` is running, the admin listener is enabled on + `127.0.0.1:9444`, and the administrator is logged in to the host. +* **When**: The administrator opens `http://127.0.0.1:9444` and selects the + status tab. +* **Then**: The page shows readiness, version, host UUID, uptime, each VG's + capacity, each volume's state, serving status, drift reasons, active + sessions or unknown, session state, observations, and problem operations. + No client certificate is requested. + +### CUJ 2: Administrator edits and saves configuration + +* **Given**: The agent is running from `/etc/kouja/config.yaml` with a known + good configuration. +* **When**: The administrator edits the YAML and saves. +* **Then**: The server parses the full file. On success it writes `.bak` + atomically, writes the new file atomically, and returns the new ETag plus + `config_changed` and `restart_required`. If a binding socket changed, + `restart_required` is true and the page tells the administrator to restart + the service manually. If no binding socket changed, the page says the file + was saved and the UI does not restart anything. Validation failure returns + the parser error and leaves the file and running process unchanged. + +### CUJ 3: Invalid configuration is rejected + +* **Given**: The administrator submits malformed YAML, an unknown field, a + relative path, or an invalid timeout. +* **When**: The save request reaches the server. +* **Then**: The server returns `422`, does not write the file, does not change + the running process, and shows the error in the editor. + +### CUJ 4: A bad but valid configuration fails on manual restart + +* **Given**: The new configuration parses but prevents the agent from starting, + and the admin UI was previously enabled and reached readiness so + `.last-good` exists. +* **When**: The operator restarts the service manually and it fails. +* **Then**: The UI is unavailable. The operator copies the failed file to + `config.yaml.failed`, restores `config.yaml.last-good`, and starts the + service. `.last-good` is the startup configuration, not the latest disk + file; live class changes made after startup may need to be reapplied from + the failed file. `.bak` holds the revision before the last config write and + may be older than the failed file. If `.last-good` does not exist (first + enablement), the operator uses the installer starter, `.bak`, or a shell + backup. + +### CUJ 5: Local network exposure is refused + +* **Given**: The host has a network interface with address `192.0.2.10`. +* **When**: A client on another host connects to `192.0.2.10:9444`. +* **Then**: The connection is refused because the listener is bound to a + loopback address. The mTLS API on `0.0.0.0:9443` is unaffected. + +### CUJ 6: Admin listener is disabled + +* **Given**: The configuration leaves `admin_ui.enabled` at `false`. +* **When**: The agent starts. +* **Then**: No admin port is opened and no `.last-good` file is written. The + mTLS API, storage data path, and existing VG seed behavior are unchanged. + +### CUJ 7: Operator applies a binding socket change + +* **Given**: The administrator changes `listen.address`, a target portal on + an enabled target, or the admin listener enabled/address state while the + admin listener is enabled in the running or candidate configuration. +* **When**: The save succeeds. +* **Then**: The response sets `restart_required: true` with a reason. The UI + shows a banner and does not restart. The operator runs + `systemctl restart kouja-agent` (or the environment's supervisor command). + After the process returns, the new listener is used and the UI is reached at + the new address if the admin address changed. + +### CUJ 8: Administrator manages VGs and classes live + +* **Given**: The agent is running and the admin UI is open. +* **When**: The administrator registers a new VG, deregisters an empty VG, or + adds/removes a class mapping. +* **Then**: The admin UI calls the shared VG service used by the mTLS admin + API. Class mappings use the new local class registry; the mTLS API has no + class mutation RPC. The VG or class change applies immediately, is + persisted, and does not restart the service. A VG with volumes or a class + mapping cannot be deregistered. A class change updates the config file and + the live registry. + +### CUJ 9: Administrator sees a config change without a restart + +* **Given**: The administrator changes a non-socket field, such as a timeout + or headroom. +* **When**: The save succeeds. +* **Then**: `config_changed` is true and `restart_required` is false. The page + says the file was saved. The UI does not set `restart_required` or perform a + restart for non-socket fields; startup-bound warnings may note that a + manual restart could be needed to apply them. + +-------------------------------------------------------------------------------- + +## Technical Plan (Architecture) + +### Architecture at a glance + +```text + browser on the host + | + | http://127.0.0.1:9444 (plain HTTP, no auth) + v + +---------------------------------------------------------------+ + | kouja-agent | + | | + | admin HTTP server ---> embedded assets (index.html, js, css)| + | | | + | +---> config reader/writer ---> /etc/kouja/config.yaml | + | | + .bak, .last-good | + | | | + | +---> status reader ---> SQLite + LVM capacity | + | | | + | +---> VG/class service ---> state + LVM + config | + | | + | mTLS gRPC/HTTP API on 0.0.0.0:9443 (unchanged) | + +---------------------------------------------------------------+ + + callers, CSI controller, CSI node ---> mTLS API ---> existing path +``` + +The admin listener is a second `http.Server` inside the same process. It does +not share the mTLS listener, its TLS configuration, or the gRPC gateway. It +shares the process, the SQLite store, the LVM backend, the VG/class service, +and the lifecycle context. + +### Relationship to the existing design + +`docs/designs/kouja.md` Alternative N rejected a separate admin listener and +said the admin API shares the mTLS listener with an admin certificate role. +This design supersedes that decision only for the local configuration and +status UI. The mTLS `AdminService` remains the authenticated API for volume and +administrative operations. The new loopback listener is disabled by default, +carries no application authentication, and serves only the two local views and +the live VG/class actions described here. The mTLS data path, caller API, and +systemd deployment are unchanged. + +The base design's VG model remains: `InitializeVGs` seeds +`cfg.EffectiveVGs()` on the first start (`storage.vgs` plus every VG referenced +by `storage.classes`) and the admin API is authoritative afterward. This design +does not change `InitializeVGs`. It exposes the existing register/deregister +operations through the local UI and adds a live class registry. Removing a VG +from the YAML does not deregister it; use the UI or the mTLS admin API. After +the first start, adding a class for a new VG does not register that VG; use the +VG panel first. + +### Component responsibilities + +* **`internal/config`**: owns `admin_ui`, `LoadWithData`, the shared + `StorageClassForVG` helper, and a structured `storage.classes` updater that + preserves YAML ordering and comments for live class changes. +* **`internal/agent/vg_service.go`**: a new shared VG service used by the + mTLS `AdminService` and the local UI. It owns register, deregister, and list + logic, including eligibility, capacity, state-store writes, and the + "VG still has volumes" guard. +* **`internal/agent/class_registry.go`**: a new live class registry with a + read/write mutex. It resolves classes for volume creation and reports class + labels for status. It persists changes through the config updater. +* **`internal/state`**: new read-only queries for status (volume status rows, + state counts, problem operations, and operation count). No schema migration. +* **`internal/adminui`**: the admin HTTP server, embedded assets, config + writer, audit writer, status assembly, and VG/class handlers. +* **`web/`**: Preact/TypeScript/Vite source. It has a status tab, a + configuration tab, and live VG/class panels. No router or component library. +* **`Makefile`**: `ui` builds the frontend; `build` builds the full binary; + `build-go` builds the Go binary with the fallback page and no Node. +* **`.github/workflows/ci.yml`**: Node 22.12 for `make build`, plus the + existing Go checks. +* **`cmd/kouja-agent/main.go`**: wires the admin server, the shared VG + service, and the live class registry. Admin listener failures are + non-fatal. No restart path. +* **`internal/agent/startup.go`**: not changed. It keeps the first-start VG + seed and VG verification. + +The local UI does not call the mTLS gRPC services. It calls the shared VG +service and the new class registry, and reads the state store and LVM backend +directly. Volume lifecycle mutations remain on the mTLS services. Class +mutations are local-UI-only; remote automation can edit `storage.classes` and +restart, or use a future mTLS class RPC. + +### Configuration schema + +The configuration file gains one optional section: + +```yaml +admin_ui: + enabled: false + address: 127.0.0.1:9444 + client_cert_dir: /etc/kouja/tls/clients +``` + +Rules: + +* `enabled` defaults to `false`. When false, no admin listener is created, + no `.last-good` is written, and no UI goroutine runs. +* `address` defaults to `127.0.0.1:9444`. The host must be the exact IP + literal `127.0.0.1` or `::1`. The rest of `127.0.0.0/8` and hostnames are + rejected as bind addresses. The port must be numeric from 1 through 65535. +* `client_cert_dir` defaults to `/clients` + and must be an absolute path when set. The local UI may list and download + regular `.crt`, `.key`, and `.pem` files directly inside this directory; + symlinks and files outside it are never served. Do not point it at the TLS + root, CA key, or server key directory. +* The loopback rule is validated whenever the section is present, even when + `enabled` is false. An invalid `admin_ui` section is a configuration error + and fails startup. The "non-fatal admin listener" rule applies only after + configuration parsing. +* The file path comes from `--config`; it is not a configuration field. The + admin server receives the same path. +* `InitializeVGs` seeds `cfg.EffectiveVGs()` on the first start: + `storage.vgs` plus every VG referenced by `storage.classes`. After that, + `storage.vgs` is not a live registration source. `storage.classes` is the + initial class map, loaded on every start. VG register/deregister is + available on the admin UI and the mTLS admin API; class set/remove is + local-UI-only. Removing a VG from the YAML does not deregister it. +* Existing configuration files remain valid; the new section is optional. +* The accepted deployment precondition is a single-administrator host with no + untrusted local users, no host-network containers, and no SSRF-capable + local service. The installer and README must state it. + +### Binding socket detection + +`restart_required` is true only when one of these fields differs between the +running and parsed disk configurations: + +* `listen.address`; +* `admin_ui.enabled`; +* `admin_ui.address`, but only when the admin listener is enabled in the + running or candidate configuration; +* `targets.iscsi.enabled`; +* `targets.iscsi.portals` (address and port values, order-insensitive), but + only when iSCSI is enabled in the running or candidate configuration; +* `targets.nvme_tcp.enabled`; +* `targets.nvme_tcp.portals` (address and port values, order-insensitive), but + only when NVMe/TCP is enabled in the running or candidate configuration. + +`config_changed` is true when any parsed field differs, after normalizing +`storage.vgs` as a set (sort and deduplicate) and treating map keys +order-insensitively. Comments, indentation, and mapping key order do not count; +target portal order is meaningful and counts as a change. The response includes +one deterministic `restart_reason` when multiple socket fields change, using +this precedence: `listen_address_changed`, `admin_enabled_changed`, +`admin_address_changed`, `iscsi_enabled_changed`, `iscsi_portals_changed`, +`nvme_tcp_enabled_changed`, `nvme_tcp_portals_changed`. If a listener is +disabled in both running and candidate configurations, its address/portal +change does not produce a reason. No other field sets `restart_required`. + +### HTTP interface + +| Method | Path | Success | Error statuses | Notes | +|---|---|---|---|---| +| `GET` | `/` | `200` | `405`, `503` | Embedded `index.html`, or the fallback page with `503`. | +| `GET` | `/assets/*` | `200` | `404`, `405` | Hashed Vite bundle only. Directory and dot-file requests return `404`. | +| `GET` | `/favicon.ico` | `204` | `405` | Empty response. | +| `GET` | `/api/status` | `200` | `500`, `503` | `503` only when SQLite cannot be read; `500` only if the running configuration cannot be marshaled. Partial VG/config/metrics errors stay `200`. | +| `GET` | `/api/config` | `200` | `500` | Disk problems return `200` with `disk_valid:false` and `disk_error_code`; `500` only if the running configuration cannot be marshaled. | +| `PUT` | `/api/config` | `200` | `400`, `409`, `413`, `415`, `422`, `428`, `500` | Writes the file. Returns `config_changed`, `restart_required`, and the new ETag. Never restarts. | +| `GET` | `/api/vgs` | `200` | `500` | Registered and configured VGs with capacity and errors. | +| `POST` | `/api/vgs` | `201` | `400`, `409`, `422`, `500` | Body `{"name":"vg"}`. Registers live. | +| `DELETE` | `/api/vgs/{name}` | `204` | `404`, `409`, `500` | Deregisters live. `409` when volumes remain or a class mapping references the VG. | +| `GET` | `/api/classes` | `200` | `500` | Current class-to-VG map. | +| `PUT` | `/api/classes/{name}` | `200` | `400`, `409`, `422`, `500` | Body `{"vg":"vg"}`. Sets a class mapping live and persists it. | +| `DELETE` | `/api/classes/{name}` | `204` | `404`, `409`, `500` | Removes a class mapping live and persists it; `409` on config-lock conflict. | +| `POST` | `/api/targets/disable` | `204` | `400`, `404`, `409`, `415`, `422`, `500`, `503` | Body `{"owner":"...","volume_id":"..."}`. Force-unexports only an iSCSI target, drops active sessions, and retains the SQLite volume/export definition. | +| `POST` | `/api/targets/enable` | `204` | `400`, `404`, `409`, `415`, `422`, `500`, `503` | Body `{"owner":"...","volume_id":"..."}`. Recreates a disabled iSCSI target from its stored definition and reuses the active credential. | +| `GET` | `/api/certificates` | `200` | `500` | Lists regular `.crt`, `.key`, and `.pem` files in `admin_ui.client_cert_dir` and, separately, the public `listen.tls.client_ca_file` as `ca`. A missing/unsafe directory is reported in the body, not as a crash. | +| `GET` | `/api/certificates/{name}` | `200` | `404`, `405` | Downloads one allowed file with `Content-Disposition: attachment`. Path traversal, symlinks, and non-allowlisted names are `404`. | +| `GET` | `/api/ca` | `200` | `404`, `405` | Downloads `listen.tls.client_ca_file` (the public CA certificate only; the CA private key is never served). | + +All API errors use `Content-Type: application/json` and: + +```json +{"error": "human-readable message", "code": "invalid_config"} +``` + +Codes: `bad_request`, `forbidden`, `not_found`, `method_not_allowed`, +`conflict`, `too_large`, `unsupported_media_type`, `invalid_config`, +`precondition_required`, `internal_error`, `unavailable`, `vg_unavailable`, +`vg_in_use`, `vg_conflict`, `class_conflict`, `invalid_target`, `volume_in_drift`, +`session_active`, `session_unknown`, `operation_in_progress`, `already_exported`. + +Global Host and mutation rules: + +* `Host` must be `127.0.0.1:`, `[::1]:`, or + `localhost:` with the actual listener port (including an injected + ephemeral port). Otherwise `403`. The `localhost` form is a Host-header + alias only; it is not a valid bind address. +* Mutations require exact Host and a non-simple content type: config `PUT` + uses `Content-Type: application/yaml`; all other `PUT`/`POST`/`DELETE` + mutations use `Content-Type: application/json`. +* If `Origin` is present, it must be `http://` plus an allowed Host value. + `Origin: null` is rejected. `Sec-Fetch-Site: cross-site` is rejected with + `403`. `GET`/`HEAD` may omit `Origin`; mutations may omit it only when + their content type is non-simple. +* No CORS headers. `OPTIONS` returns `405`. +* Global `403` (Host/Origin), `404` (unknown route), and `405` (wrong method + or `OPTIONS`) apply to every route. API responses use the JSON error body; + the HTML fallback uses plain HTML. + +### Status response + +```json +{ + "agent": { + "version": "dev", + "api_version": "v1", + "host_uuid": "6f1c...", + "ready": true, + "started_at": "2026-11-07T07:00:00Z", + "uptime_seconds": 1234, + "config_path": "/etc/kouja/config.yaml", + "config_changed": false, + "restart_required": false, + "restart_reason": "", + "last_good_error": "", + "listen_address": "0.0.0.0:9443", + "admin_address": "127.0.0.1:9444", + "protocols": ["iscsi"] + }, + "iscsi": { + "enabled": true, + "iqn_prefix": "iqn.2026-11.io.kouja", + "portals": ["0.0.0.0:3260"] + }, + "metrics": { + "sampled_at": "2026-11-07T07:00:05Z", + "available": true, + "rates_available": true, + "error": "", + "read_bytes": 8309504, + "write_bytes": 1110503424, + "read_bytes_per_second": 1048576, + "write_bytes_per_second": 1572864, + "commands": 888, + "commands_per_second": 12.5, + "error_count": 0, + "connections": [ + { + "client_address": "192.0.2.10", + "client_port": 40000, + "local_address": "192.0.2.1", + "local_port": 3260, + "target": "iqn.2026-11.io.kouja:host:uuid", + "initiator": "iqn.2026-11.io.example:consumer", + "ambiguous": false + } + ], + "connections_error": "", + "targets": [ + { + "target": "iqn.2026-11.io.kouja:host:uuid", + "volume_owner": "rookery", + "volume_id": "example-1", + "sessions": 1, + "initiators": [ + { + "initiator": "iqn.2026-11.io.example:consumer", + "rx_bytes": 1110503424, + "tx_bytes": 8309504, + "command_pdus": 888, + "response_pdus": 311, + "connection_timeout_errors": 0, + "connection_digest_errors": 0 + } + ], + "luns": [ + { + "lun": 0, + "resource_uuid": "uuid", + "commands": 888, + "read_bytes": 7340032, + "write_bytes": 1125122048, + "resets": 0, + "aborts_complete": 0, + "aborts_no_task": 0, + "status": "available", + "state_bit": "exposed", + "busy_count": 0, + "in_commands": 888, + "high_priority_in_commands": 0, + "error": "" + } + ], + "client_addresses": ["192.0.2.10"], + "read_bytes": 8309504, + "write_bytes": 1110503424, + "read_bytes_per_second": 1048576, + "write_bytes_per_second": 1572864, + "commands": 888, + "commands_per_second": 12.5, + "rates_available": true, + "error_count": 0, + "errors": { + "login_failures": 0, + "authentication_failures": 0, + "authorization_failures": 0, + "negotiation_failures": 0, + "other_login_failures": 0, + "abnormal_logouts": 0, + "format_errors": 0, + "connection_errors": 0, + "digest_errors": 0, + "connection_timeouts": 0, + "connection_digest_errors": 0, + "aborts_complete": 0, + "aborts_no_task": 0, + "resets": 0 + }, + "error": "" + } + ] + }, + "vgs": [ + { + "name": "kouja", + "uuid": "vg-uuid", + "state": "active", + "storage_class": "hdd", + "configured": true, + "total_bytes": 107374182400, + "free_bytes": 53687091200, + "allocatable_bytes": 48318382080, + "extent_size_bytes": 4194304, + "max_volume_bytes": 1099511627776, + "observed_at": "2026-11-07T07:00:00Z", + "error": "" + } + ], + "volumes": [ + { + "owner": "rookery", + "volume_id": "example-1", + "resource_uuid": "uuid", + "lv_name": "kouja-uuid", + "vg_name": "kouja", + "storage_class": "hdd", + "requested_size_bytes": 1073741824, + "actual_size_bytes": 1073741824, + "state": "exported", + "serving": true, + "drift_reasons": [], + "updated_at": "2026-11-07T07:00:00Z", + "export": { + "protocol": "iscsi", + "initiator": "iqn.2026-11.io.example:consumer", + "target_name": "iqn.2026-11.io.kouja:host:uuid", + "lun": 0, + "nsid": 0, + "portal": "192.0.2.10:3260", + "state": "exported" + }, + "observation": { + "lv_present": true, + "observed_size_bytes": 1073741824, + "device_path": "/dev/kouja/kouja-uuid", + "observed_at": "2026-11-07T07:00:00Z", + "source": "lvm", + "stale": false + }, + "export_observation": { + "target_present": true, + "namespace_present": true, + "acl_present": true, + "active_sessions": 1, + "session_state": "active", + "observed_initiator": "iqn.2026-11.io.example:consumer", + "initiator_source": "acl", + "observed_at": "2026-11-07T07:00:00Z", + "stale": false + } + } + ], + "counts": { + "volumes": 1, + "by_state": {"exported": 1}, + "truncated_volumes": false, + "operations": 1, + "truncated_operations": false + }, + "operations": [ + { + "id": "op-1", + "owner": "rookery", + "volume_id": "example-1", + "resource_uuid": "uuid", + "action": "create", + "state": "failed", + "attempt": 3, + "max_attempts": 3, + "deadline_at": "2026-11-07T07:05:00Z", + "last_error_code": "UNAVAILABLE", + "last_error_retryable": true, + "created_at": "2026-11-07T07:00:00Z", + "updated_at": "2026-11-07T07:01:00Z" + } + ], + "errors": [ + {"code": "capacity_unavailable", "message": "vg kouja-hdd: vgs failed"} + ] +} +``` + +Rules: + +* `serving` is true only when the volume and export states are `exported`, + `observation.lv_present` is true, the target and ACL are present, the + namespace is present for `nvme-tcp`, and both observations are fresh. If + either observation is missing or `stale`, `serving` is false and the UI + shows `unknown`/stale rather than asserting healthy. +* `export`, `observation`, and `export_observation` are `null` when the + corresponding row has not been observed. The UI renders `unknown`, and + `serving` is false. A volume before its first reconcile therefore shows + observation `unknown`, not `lv_present:false`. +* `active_sessions` is `null` when `session_state` is `unknown`. iSCSI + reports a count when the kernel exposes it; nvmet reports `unknown` on the + supported kernel. +* `observed_initiator` is the reconciler's stored + `export_observations.observed_initiator` when non-empty, and + `initiator_source` is `"acl"` because the current adapters copy the + configured ACL initiator; it is not a kernel session identity. When the + `export_observation` row exists but the stored value is empty, + `observed_initiator` is null and `initiator_source:"none"`. When the row is + absent, `export_observation` itself is null and there is no + `initiator_source` field. The configured initiator is `export.initiator`. + A future session-verified value would use `initiator_source:"session"`. +* `stale` is a boolean in the API, true when `observed_at` is missing or + older than `2 * state.reconcile_interval_seconds` (default 120s). The UI + renders a missing or stale observation as `unknown`, with a stale badge + where useful. +* Every array (`vgs`, `volumes`, `operations`, `drift_reasons`, `errors`, + `protocols`, `metrics.connections`, `metrics.targets`, + `metrics.targets[].initiators`, and `metrics.targets[].luns`) is `[]`, + never `null`. +* `metrics` is best-effort. `available:false` with a non-empty `error` means + the collector could not read LIO configfs; the status response remains + `200`. A missing iSCSI target tree is `available:true` with no targets. + `rates_available` is false on the first sample or when samples are closer + than one second; rates are computed from cumulative counters between + successful samples. A counter reset makes that target's + `rates_available:false` and leaves its rates at zero. +* Target-level `read_bytes` is the sum of initiator `tx_bytes` (target sent + to initiators), and `write_bytes` is the sum of initiator `rx_bytes` + (target received from initiators). LUN `read_bytes`/`write_bytes` are the + coarse whole-MiB `scsi_lu` counters and are shown for LUN detail. + `commands` is the sum of LUN command counters. Error counts are cumulative + and never auto-reset; an operator can compare them across refreshes. +* Per-initiator counters come from the ACL `iscsi_sess_stats` directory. + They are aggregated per initiator when an ACL has more than one session; + the current kouja model uses one target and one session per volume. +* `metrics.connections` lists ESTABLISHED TCP sockets whose local port is a + configured target portal port from the LIO `np` directory. `client_address` + and `client_port` are the initiator endpoint; `local_address` and + `local_port` are the target portal endpoint. Target/initiator matching uses + the active session `Address` lines in ACL `info`. When several targets + share the same portal port and initiator address, the connection is marked + `ambiguous:true` instead of guessing. A connection with no matching ACL is + shown as unmatched. `/proc/net/tcp(6)` is namespace-local and best-effort; + if it is unreadable, `connections_error` reports the error and the rest of + the metrics still render. +* `counts.by_state` contains zero values for `creating`, `available`, + `exporting`, `exported`, `unexporting`, `resizing`, `deleting`, `failed`, + and `drift`. The JSON example elides zero keys for brevity; the server + emits all nine. Deleted rows are tombstones and are not in the live volume + table. +* `counts.volumes` is the total live volume rows. `truncated_volumes` is + true when the total exceeds the returned rows. `counts.operations` is the + total problem operations; `truncated_operations` compares it with the + returned rows. +* `errors` may include `config_missing`, `config_invalid`, `config_unsafe`, + or `config_unreadable` with the disk error message. These are status + diagnostics; the status response remains `200` unless SQLite fails + (`503`) or the running configuration cannot be marshaled (`500`). +* A failed VG capacity probe sets `vgs[].state` to `unavailable`, zeros the + capacity fields, sets `error`, and appends a `capacity_unavailable` entry + to `errors`. A probe that exceeds the time budget sets `state:"unknown"` + and appends `capacity_unknown`. A successful probe uses + `capacity.VGState`/`capacity.VGUUID` for display; a UUID that differs from + the registered row is `unavailable` with an error. The persisted VG row is + not changed. + +### Status data flow + +`GET /api/status` performs one SQLite transaction and then live capacity +probes: + +1. `Store.Transact`: + * `Tx.ListVGs` (existing method) for registered VGs; + * `Tx.ListVolumeStatuses(256)` for at most 256 volume rows; + * `Tx.VolumeStateCounts()` for the counts map; + * `Tx.ListRecentOperations(64)` for at most 64 problem operations; + * `Tx.OperationCount()` for the total problem-operation count. +2. Build an effective config copy with + `Storage.Classes = Classes.Snapshot()`, then merge its `EffectiveVGs()` with + registered VGs. A configured but unregistered VG gets a row with + `state: "not_registered"`, `configured: true`, empty UUID, zero capacity, + and an `error` string. The same helper backs `GET /api/vgs`. +3. Call `lvm.Backend.Capacity` for each registered VG. The handler has a total + budget of 10 seconds, a 3-second timeout per VG, and a concurrency limit of + 4. A VG that exceeds the budget is marked `unknown` with a + `capacity_unknown` error; a probe that returns an LVM error is marked + `unavailable` with `capacity_unavailable`. If the probe succeeds, the + capacity's `VGState`/`VGUUID` take precedence for display; a UUID mismatch + is surfaced as `unavailable` with an error, and the persisted VG row is not + changed. +4. Compute `config_changed`, `restart_required`, and `restart_reason` with the + same helper used by `GET /api/config`; append the matching `config_missing`, + `config_invalid`, `config_unsafe`, or `config_unreadable` entry when the + disk config is unavailable. +5. Read live iSCSI metrics through `Options.TargetMetrics` when configured. + Join target names to the volume read model for owner/volume labels. Read + target, ACL, and LUN counters from LIO configfs; read established TCP client + endpoints from `/proc/net/tcp(6)` using the target portal ports and ACL + session addresses; keep metrics collection errors in `metrics.error` or + `metrics.connections_error` instead of failing the status request. Derive + rates from the previous successful sample held in the server. +6. Return the response. + +The status handler uses a non-blocking in-flight guard. If a request is already +running, a second request waits for the first result instead of starting another +LVM probe. The shared computation uses a detached 10-second context, so one +client abort does not cancel a result another waiter needs; each waiter still +honors its own request context while waiting. The page polls every 5 seconds and +aborts the previous fetch before starting a new one. There is no cache. The +status path does not call `Admission.AcquireCapacity`; it is local and read-only. + +### State query additions + +Add four methods to the existing `Tx` interface. No schema migration and no new +indexes are required at the supported 256-volume/64-operation scale: + +* `ListVolumeStatuses(limit int) ([]VolumeStatus, error)`: one `LEFT JOIN` + from `volumes` to `exports`, `volume_observations`, and + `export_observations`, joining on `owner` and `volume_id`. Select every + volume column plus the related columns with explicit aliases. Decode + `drift_reasons` and `legacy_identity` with the existing JSON decoders. A + limit of zero or less becomes 256; larger values are capped at 256. Order + by `volumes.updated_at DESC, volumes.owner, volumes.volume_id`. +* `VolumeStateCounts() (map[string]int, error)`: group the live volume table + by state. Return an empty map when there are no rows. +* `ListRecentOperations(limit int) ([]Operation, error)`: filter `state IN + ('running', 'failed', 'needs_attention')`, order by `updated_at DESC, + id DESC`, normalize zero or less to 64, cap at 64. +* `OperationCount() (int, error)`: count the same problem-operation set. + +`VolumeStatus` embeds `Volume` and carries pointers to `Export`, +`Observation`, and `ExportObservation`. The joined tables have at most one row +per `(owner, volume_id)` by primary key. The existing `operations_by_state` +index supports the state filter. Add an index only after profiling, not in this +feature. + +### Configuration read flow + +The agent reads the file once at startup with `config.LoadWithData` and keeps: + +* `Config`: the parsed effective configuration; +* `ConfigData`: the exact raw bytes for `.last-good`. + +`configEquivalent(running, candidate *config.Config, classes ClassRegistry) bool` +compares a deterministic YAML marshal after normalizing `storage.vgs` as a +sorted, deduplicated set and replacing the running class map with the live +`Classes.Snapshot()`. Comments, indentation, and mapping key order do not +count; `storage.vgs` order/duplicates do not count. Target portal order is +meaningful and remains order-sensitive for `config_changed`. +`rawETag(data []byte)` returns the quoted string `"sha256:"`. + +`GET /api/config`: + +1. Read the file with a display reader that does not reject symlinks; it + reports the path type. +2. Compute `disk_etag` from the raw bytes, or `"*"` if missing, or `""` if the + path is unsafe/unreadable. +3. Parse the bytes with `config.Parse`. +4. Set `disk_valid`, `disk_error`, and `disk_error_code`: + * `missing`: file does not exist; `disk_yaml: ""`, `disk_etag: "*"`. + * `parse_error`: file exists but does not parse; raw YAML is preserved, + `disk_etag` is the raw hash; the UI can fix and save with `If-Match`. + * `unsafe_path`: symlink or non-regular; raw YAML is empty, + `disk_etag: ""`; `PUT` returns `422`. + * `read_error`: permission or I/O failure; `disk_etag: ""`; `PUT` + returns `500`. +5. Compute `config_changed = !disk_valid || parsedDisk != effectiveRunning`. + The effective running configuration is a copy of the startup configuration + whose `Storage.Classes` is replaced by `Classes.Snapshot()`; this makes live + class changes visible and avoids a false `config_changed` after a class + action. When `disk_valid` is false, set `restart_required:false`, + `restart_reason:""`, and put the diagnostic in `disk_error`; skip the + binding comparison because there is no parsed candidate. When valid, + compare binding sockets as defined above. +6. Return `disk_yaml`, `running_yaml` (canonical effective with the live class + map), ETag, validity, error code, `config_changed`, `restart_required`, + `restart_reason`, `last_good_error`, and `warnings`. `GET` warnings are + always `[]`; preflight warnings are produced by `PUT` only. Class mutations + return durability/audit warnings from their own write path, not preflight. + +### Configuration write flow + +`PUT /api/config`: + +1. Enforce Host, Origin, `Sec-Fetch-Site`, `Content-Type: application/yaml`, + and the 1 MiB body limit. +2. Read the current file and ETag. For an existing regular file, require + `If-Match` equal to `disk_etag`; a mismatch returns `409`. For a missing + file, require `If-None-Match: *`. If the supplied precondition form does + not match the current state, return `409`. A symlink or non-regular file + returns `422`. Missing `If-Match`/`If-None-Match` returns `428`; malformed + returns `400`. +3. Parse with `config.Parse`; invalid input returns `422` without a write. +4. Build preflight warnings with + `preflight(ctx, candidate, running, vgs)` under a 5-second context. Warnings + never block. The VG check uses `VGs.Inspect` (read-only). This runs before + the file lock so a slow LVM/bind probe cannot block class updates or other + saves. +5. Acquire the exclusive `.lock` file (`unix.Flock`). If the lock is + not acquired within 2 seconds, return `409`. +6. Re-read the current file's size, modification time, and raw hash. If it + changed after step 2, return `409` and leave the original intact. +7. If a current file exists, write its bytes to `.bak` with + `internal/fsutil.WriteFileAtomic` (mode `0600`). If this fails, abort with + `500`; the original is unchanged. A missing-file create skips `.bak` + because there is no previous file. +8. Write the new YAML with `fsutil.WriteFileAtomic`. If temp write or rename + fails, the original remains. If the directory sync fails after rename, + `fsutil.ErrDurabilityUncertain` is converted to a successful response with + a `durability_uncertain` warning, because the new file is visible. +9. Release `.lock` immediately after the rename/recheck critical + section. Then compute `config_changed` and `restart_required` from the new + parsed config; `configState` may take the live class snapshot without + holding the file lock. This preserves the `ClassMap.mu` -> file-lock order + used by class writes. +10. Write a `config_save` audit row (best effort; failure adds + `audit_write_failed` warning). Build and flush the response, including the + new `disk_etag`, `config_changed`, `restart_required`, and + `restart_reason`. +11. Do not restart, schedule a restart, or exit the process. + +`preflight(ctx, candidate, running *config.Config, vgs VGService)` warnings: + +* TLS cert/key/CA file missing or unreadable; +* `secret_key_file` changed (existing credentials may become unreadable); +* data/backup directory parent not writable; +* a changed mTLS listen address cannot be bound (skip if unchanged); +* a changed admin address cannot be bound (skip if unchanged; skip when + disabled); +* configured VG missing or ineligible (uses `VGs.Inspect`; read-only); +* `admin_ui.enabled` changed to false (UI disappears after restart); +* `admin_ui.address` changed (UI moves after restart); +* identity or state paths changed; +* target portals or protocol enable flags changed; +* target portal order changed (future exports after restart use the new + first portal; this is a warning, not a binding-socket change); +* `listen.tls` changed (startup-bound; may require a manual restart to load + new material); +* operations, admission, storage headroom, or state intervals changed + (startup-bound; may require a manual restart to apply); +* `storage.vgs` changed in raw YAML (part of the first-start + `EffectiveVGs()` seed; use the live VG panel); +* `storage.classes` changed in raw YAML (loaded every start; a class for a + never-registered VG still needs the live VG panel; use the class panel for + immediate effect); +* any other parsed difference (`config_changed`). + +Warnings have the shape `{"code": "...", "message": "..."}`. `PUT` returns +`200` with warnings; they never block a valid save. Only the binding socket +fields set `restart_required`; startup-bound warnings may advise a manual +restart but do not set it. + +### Configuration responses + +`GET /api/config` returns: + +```json +{ + "path": "/etc/kouja/config.yaml", + "backup_path": "/etc/kouja/config.yaml.bak", + "last_good_path": "/etc/kouja/config.yaml.last-good", + "disk_yaml": "host_id_file: /var/lib/kouja/host-id\n...", + "running_yaml": "host_id_file: /var/lib/kouja/host-id\n...", + "disk_etag": "\"sha256:...\"", + "disk_valid": true, + "disk_error": "", + "disk_error_code": "", + "config_changed": false, + "restart_required": false, + "restart_reason": "", + "last_good_error": "", + "warnings": [], + "tls": { + "subject": "kouja-agent", + "server_names": ["storage.example", "localhost"], + "ip_addresses": ["127.0.0.1", "192.0.2.10"], + "not_after": "2028-11-07T00:00:00Z", + "error": "" + } +} +``` + +`PUT /api/config` returns: + +```json +{ + "saved": true, + "path": "/etc/kouja/config.yaml", + "backup_path": "/etc/kouja/config.yaml.bak", + "disk_etag": "\"sha256:...\"", + "config_changed": true, + "restart_required": true, + "restart_reason": "listen_address_changed", + "warnings": [ + {"code": "durability_uncertain", "message": "directory sync failed after rename"} + ] +} +``` + +`GET /api/vgs` returns `{"vgs":[VGStatus...]}` using the same VG object as +`/api/status`. `POST /api/vgs` returns `201` with +`{"vg": VGStatus, "warnings": []}`. `DELETE /api/vgs/{name}` returns `204`; +audit failure is logged only. `GET /api/classes` returns +`{"classes":{"class":"vg"}}`. `PUT /api/classes/{name}` returns +`{"classes":{"class":"vg"},"config_changed":false,"restart_required":false,"warnings":[]}`; +`config_changed` is recomputed after the write and may be true if a concurrent +raw edit left the disk different. `DELETE /api/classes/{name}` returns `204`; +audit failure is logged only. These mutations never restart. The class file +write and the live map swap are one logical action; the live map is updated +before the response, so the running and disk views match. + +`POST /api/targets/disable` accepts an owner and volume ID for an existing +iSCSI export. The server verifies the export protocol, then calls the shared +operation manager with a forced unexport. The target adapter disables the TPG +and removes the target/ACL, dropping active sessions; the manager retains the +volume and export rows and records `volume.state:"available"` / +`export.state:"unexported"`. The endpoint returns `204` with no body. It is +idempotent per target generation and creates a distinct operation after a +later re-export because the admin action carries a unique request ID in the +unexport intent. + +`POST /api/targets/enable` accepts the same owner and volume ID. It loads the +stored iSCSI export definition and runs an export operation using the retained +target, initiator, portal, and CHAP credential fields. The admin disable path +sets `keep_credential`, so the active credential is not superseded; enable +reinstalls the same credential on the recreated target. The volume and export +return to `exported` state. The action carries a unique request ID so +disable/enable cycles do not replay an older operation. + +### Last known-good configuration + +When the admin UI is enabled, after the agent has loaded the configuration, +both listeners are serving, and readiness is set, the admin server writes the +startup bytes to `.last-good` with mode `0600` using +`fsutil.WriteFileAtomic`. Neither a raw configuration save nor a live class +write refreshes it. It is the startup configuration that last reached +readiness, so recovery never promotes a disk-only edit that was not tested by a +successful start. Live class changes after startup are in the current (possibly +failed) config; `.bak` holds the revision before the last write and may not +contain the latest class change. CUJ 4 tells the operator to reapply them if +recovery is needed. + +The write is best effort. If it fails, the server logs the error, reports +`last_good_error` in `GET /api/config`, and still allows saves; `.bak` is the +immediate undo. If the write fails, recovery is manual. On first enablement, +`.last-good` does not exist yet; CUJ 4 requires a prior successful +admin-enabled startup. The operator can use `.bak`, the installer starter, or a +shell backup. When `admin_ui.enabled` is false, no `.last-good` is written. + +### Configuration audit + +`writeAudit(ctx context.Context, store Store, action, operationID, preState, +postState, result, errorCode, remote string) error` writes one row through +`state.PutAudit`. It assigns `caller: "admin-ui"`, `source_ip` as the host part +of `RemoteAddr`, and the supplied action/result/error. The operation ID is a +UUID. `preState`/`postState` map to `state.Audit.PreState`/`PostState`; there +are no hash columns. Actions: + +* `config_save` — successful save only; `result: succeeded`; +* `vg_register`, `vg_deregister`; +* `class_set`, `class_remove`. + +Rejected config saves are logged with a request ID, not audited. The audit write +happens after the file/VG/class mutation and before the JSON response is built. +If it fails, the mutation still succeeds; a `200`/`201` response includes +`audit_write_failed`, and the server logs the error. A `204` delete cannot carry +a body, so its audit failure is log-only. There is no restart audit because the +UI never restarts the service. + +### Live VG service + +`internal/agent/vg_service.go` owns: + +```go +type VGService interface { + Register(ctx context.Context, name, expectedUUID string, replace bool) (lvm.Capacity, error) + Deregister(ctx context.Context, name string) error + List(ctx context.Context) ([]state.VG, error) + Inspect(ctx context.Context, name string) (lvm.Capacity, error) +} +``` + +`Inspect` runs `lvm.Eligibility` and `lvm.Capacity` read-only; it never writes +state. `Register` validates the name, calls `Inspect`, and first checks the +requested UUID: if `expectedUUID` is non-empty and differs from the observed +`Capacity.VGUUID`, return `ErrVGUUIDMismatch` before any row logic. This +preserves the existing mTLS check for new and existing rows. Then: + +* no existing row: insert the observed UUID, `active` state, and timestamps; +* existing row with the same observed UUID: idempotent success; refresh + `state:"active"` and `updated_at`; +* existing row with a different observed UUID and `replace=false`: return + `ErrVGConflict` (the local UI passes false and returns `409`); +* existing row with a different observed UUID and `replace=true`: update the + row. An empty `expectedUUID` skips the UUID check and preserves the legacy + upsert behavior. The mTLS `AdminService.RegisterVG` passes `replace=true` + and `request.GetVgUuid()`; the local UI passes `replace=false` and an empty + UUID. + +`Deregister` rejects a VG that still has live volumes (`ErrVGInUse`) or a VG +referenced by a class mapping (`ErrVGClassMapped`) before deleting it. `List` +returns state rows. The mTLS `AdminService.RegisterVG`/`DeregisterVG` delegate +to this service; `ListVGs` uses `VGService.List` for state rows and keeps its +existing capacity/class loop. The local UI uses the shared `buildVGStatus` +helper for capacity and configured state. The local UI does not use a synthetic +admin identity. Exact gRPC mapping: `ErrVGInUse`, `ErrVGUnavailable`, and +`ErrVGClassMapped` -> `FailedPrecondition`; `ErrVGUUIDMismatch` -> +`FailedPrecondition`; `ErrVGNotFound` -> `NotFound`; `ErrVGConflict` -> +`AlreadyExists`. Local HTTP mapping is in the error table. + +### Live class registry + +`internal/agent/class_registry.go` owns a mutex-protected map. It does not depend +on `VGService`; the local handler validates registration before calling it, and +`VGManager` uses it for the deregistration guard. This avoids a construction +cycle. + +```go +type ClassRegistry interface { + Snapshot() map[string]string + ClassName(vg string) string + Resolve(class, vg string) (resolvedClass, resolvedVG string, err error) + Set(ctx context.Context, class, vg string) (warnings []string, err error) + Remove(ctx context.Context, class string) (warnings []string, err error) +} +``` + +The registry is initialized from `cfg.Storage.Classes`. `VolumeService` and +`AdminService` use it instead of a static map. `Set`/`Remove` hold the registry +write lock across the complete config read/modify/write and the map swap, so a +concurrent `Set`/`Remove` cannot commit the file and map in different orders. +The config file lock is acquired inside the registry lock; the lock order is +`ClassMap.mu` then `.lock`, never the reverse. A successful write does +not refresh `.last-good`; `durability_uncertain` on post-rename fsync failure +is a warning. The local handler checks that the VG is registered before `Set`; +a missing VG returns `vg_unavailable`. A class name must match the existing +name pattern. A class cannot map to a VG that another class already uses. +Changes persist across restarts and do not restart the service. The +`PUT /api/classes` response recomputes `config_changed`/`restart_required` via +`configState` after the write; a class-only change normally has +`config_changed:false` and `restart_required:false`. `Remove` returns +`config.ErrClassNotFound` for a missing class. + +`storage.classes` is the initial class map and is loaded on every start; raw +YAML edits to it apply on restart. `cfg.EffectiveVGs()` is the first-start seed; +`storage.vgs` alone is not. The live class panel applies a mapping immediately +and updates the file; adding a class for a VG that was never registered still +requires the VG panel. + +### Embedded assets and build + +The browser application lives under `web/` and is built with Preact, +TypeScript, and Vite. `npm run build` runs from `web/` and writes +`../internal/adminui/assets`, which resolves to +`internal/adminui/assets` from the repository root. The Go package embeds the +directory with `//go:embed all:assets`; `fallback.html` is embedded separately +with `//go:embed fallback.html`. A committed `.gitkeep` and nested `.gitignore` +keep the generated directory present; generated files are ignored by Git. + +Build contracts: + +* `web/package.json` pins `preact@11.2.0`, `vite@8.3.6`, `typescript@7.0.2`, + and `@preact/preset-vite@2.10.6`. Node `22.12.0` is the build runtime. +* Scripts: `dev: vite`, `build: tsc --noEmit && vite build`, + `typecheck: tsc --noEmit`, `preview: vite preview`. +* `vite.config.ts`: `base: "/"`, `build.target: "es2022"`, + `build.modulePreload.polyfill: false`, + `build.outDir: "../internal/adminui/assets"`, `emptyOutDir: false`. + The dev proxy sends `/api` to `http://127.0.0.1:9444`, sets + `changeOrigin: true`, and rewrites `Origin` to `http://127.0.0.1:9444`. +* `make clean-assets`: remove generated files in + `internal/adminui/assets` except `.gitkeep` and `.gitignore`. +* `make ui`: `clean-assets`, `cd web && npm ci`, `cd web && npm run build`, + then verify `internal/adminui/assets/index.html` exists. +* `make build`: depends on `ui`, then `go build ./...`. +* `make build-go`: depends on `clean-assets`, then `go build ./...`; the + binary serves the fallback page even if `make build` ran previously. This + is the Node-free path. +* CI sets up Node 22.12.0, caches `web/package-lock.json`, runs `make build`, + and runs the Go checks. The quality job runs `make build-go` from a clean + asset directory and checks the fallback path. + +The Preact/Vite toolchain is a product constraint from the design review. The +fallback page keeps `go build` usable without Node; the full UI requires +`make build` and Node. The ongoing cost is a second language ecosystem and a +generated asset directory. + +### Security controls + +* Listener binds only `127.0.0.1` or `::1`; the bound address is checked after + `net.Listen`. +* No authentication. Any local process that can reach the port is + root-equivalent through the config editor, VG registration, and class + changes. The accepted precondition is a single-administrator host with no + untrusted local users, no host-network containers, and no SSRF-capable + local service. SSRF clients can set Host/Origin headers; the loopback + location is the only control. +* Host allowlist as above; `403` on mismatch. +* `Origin` same-origin when present; `Origin: null` rejected; + `Sec-Fetch-Site: cross-site` rejected. +* `PUT /api/config` requires `application/yaml`; VG/class mutations require + `application/json`. A cross-site HTML form cannot send these non-simple + types without a preflight, and no CORS/preflight is supported. +* CSP: `default-src 'self'; script-src 'self'; style-src 'self'; img-src + 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; + form-action 'self'`. +* Headers: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, + `Referrer-Policy: no-referrer`; API/index `Cache-Control: no-store`; + hashed assets `public, max-age=31536000, immutable`. +* Timeouts: `ReadHeaderTimeout: 5s`, `ReadTimeout: 15s`, `WriteTimeout: 30s`, + `IdleTimeout: 60s`, `MaxHeaderBytes: 1 MiB`. Status has a 10s total budget. +* Config body limit 1 MiB. Config, backup, last-good, and lock files are mode + `0600`. The admin writer rejects symlinks and non-regular files. +* Status and config responses never include credentials, grants, the secret + key, or the credential table. The raw config is returned because it contains + paths and policy, not secrets. +* No restart endpoint, no `exec`, and no systemd command from the UI. + +### Process lifecycle + +1. Load the configuration with `config.LoadWithData`. +2. Run the existing directory, dependency, lock, store, host identity, + credential, LVM, operation manager, reconciler, and first-start VG seed. + `InitializeVGs` is unchanged. +3. Create the mTLS server. +4. If `admin_ui.enabled`, create the admin server. A bind or construction + failure is logged, sets `kouja_admin_ui_up` to 0, and leaves + `adminServer = nil`; the mTLS server and storage path continue. +5. Start the backup and reconcile goroutines. +6. Set readiness. +7. Start the admin `Serve` goroutine and call `MarkRunning` after readiness. + If `MarkRunning` fails, log it, set `last_good_error`, and continue; startup + is not aborted. A later admin `Serve` error is logged, sets the gauge to 0, + calls `adminServer.Shutdown(ctx)` with a 5-second context, and disables the + admin server. It never calls the process stop callback. +8. Wait on the signal context and the mTLS server error. The admin error is + handled in its own goroutine. +9. On shutdown, stop the admin server with a 5-second bounded context, stop the + mTLS server, wait for the reconciler and backup goroutines, and return. +10. When disabled, do not create the admin server, write `.last-good`, or set + the admin gauge to 1; set `kouja_admin_ui_up` to 0 at startup. + +`startAdminServer(cfg *config.Config, options adminui.Options) *adminui.Server` +returns nil when disabled, uses `options.Logger` to log and return nil on `New` +failure, and otherwise returns the server. `main_test.go` covers disabled, bind-failure, and free-port +cases. There is no restart sentinel or exit-code mapping. + +### Package API + +```go +type Store interface { + Transact(context.Context, func(state.Tx) error) error +} + +type CapacityProvider interface { + Capacity(context.Context, string) (lvm.Capacity, error) +} + +type Options struct { + ConfigPath string + Config *config.Config + ConfigData []byte + Store Store + Capacity CapacityProvider + VGs agent.VGService + Classes agent.ClassRegistry + Ready func() bool + HostUUID string + AgentVersion string + APIVersion string + StartedAt time.Time + Logger *slog.Logger + Now func() time.Time + Assets fs.FS + Listener net.Listener + Metrics *observability.Metrics + TargetMetrics target.ISCSIMetricsProvider + DisableTarget TargetDisabler + EnableTarget TargetEnabler + VGClassGate *sync.Mutex +} + +type Server struct { /* unexported */ } + +func New(options Options) (*Server, error) +func (server *Server) Handler() http.Handler +func (server *Server) Serve() error +func (server *Server) Shutdown(ctx context.Context) error +func (server *Server) Address() net.Addr +func (server *Server) MarkRunning() error +``` + +Defaults: `Logger` = `slog.Default()`, `Now` = `time.Now`, `StartedAt` = +`time.Now()`, `Assets` = embedded root, `Ready` = always true, `VGClassGate` = +a new `sync.Mutex`. `New` returns an error when `ConfigPath` is empty, when +`Config`, `ConfigData`, `Store`, +`Capacity`, `VGs`, or `Classes` is nil, or when +`Config.AdminUI.Enabled` is false. It binds `Config.AdminUI.Address` unless +`Listener` is non-nil, verifies the bound address is loopback, derives the +allowed Host set from the actual listener port, stores the parsed running +configuration, and creates the HTTP server. `Serve` sets +`kouja_admin_ui_up` to 1 immediately before calling `http.Serve` (after the +listener is bound), resets it to 0 when `http.Serve` returns, and returns nil +for `http.ErrServerClosed`. `Shutdown` also sets it to 0. `MarkRunning` writes +`.last-good` from `ConfigData` and records `last_good_error`. + +Handler ownership: `server.go` owns routes, middleware, and the HTTP handlers. +`status.go` builds the status view model. `config_file.go` owns read/save file +operations. `vg.go` owns VG/class handlers. `audit.go` owns audit writes. +`types.go` owns JSON types. No file owns restart logic because the UI never +restarts. + +### Error handling and failure modes + +| Condition | Behavior | +|---|---| +| Invalid YAML or invalid field | `422`, no write, running process unchanged. | +| File changed since page load | `409`, reload the editor. | +| Config file is a symlink/non-regular | `GET` 200 with `disk_valid:false`, `disk_error_code:"unsafe_path"`; `PUT` `422`. | +| Disk full or temp/rename failure | `500`, original intact. | +| Directory fsync fails after rename | `200` with `durability_uncertain` warning; new file visible, `.bak` recovery. | +| `.bak` write fails (existing file) | `500`, new file not written; missing-file create has no `.bak` step. | +| `.last-good` write fails | Logged and reported via `last_good_error`; startup and saves continue. | +| Admin listener bind fails | Logged; gauge 0; mTLS and storage continue. | +| Admin `Serve` fails | Logged; gauge 0; `adminServer.Shutdown`; mTLS and storage continue. | +| Invalid `admin_ui` config | Startup fails during config parsing; this is a config error, not a listener failure. | +| VG register invalid/ineligible | `422` with `vg_unavailable`; no state change. | +| VG register existing with same UUID | `201` idempotent success. | +| VG register existing with different UUID | `409` with `vg_conflict`; no overwrite. | +| VG deregister with volumes | `409` with `vg_in_use`; no state change. | +| VG deregister with a class mapping | `409` with `class_conflict`; no state change. | +| Class VG not registered | `422` with `vg_unavailable`; no config or registry change. | +| Class VG already mapped by another class | `409` with `class_conflict`; no config or registry change. | +| Class remove missing | `404` with `not_found`; no config change. | +| Class lock/unsafe/I/O failure | `409` for lock; `422` for unsafe path; `500` for I/O; live registry unchanged on failure. | +| Target disable with active sessions | Forced unexport; `204`; target/ACL removed, sessions dropped, volume/export rows retained. | +| Target disable on a non-iSCSI export | `422` with `invalid_target`; no target or state change. | +| Target disable on a missing definition | `404` with `not_found`; no target or state change. | +| Target enable | Target recreated from the stored definition with the same CHAP credential; `204`; the volume/export returns to exported state. | +| Target enable while already exported | Idempotent export reconciliation; `204` or a mapped conflict if the target cannot be recreated. | +| Config changed, binding socket unchanged | `200`, `config_changed:true`, `restart_required:false`. | +| Binding socket changed | `200`, `config_changed:true`, `restart_required:true`; operator restarts manually. | +| LVM capacity probe fails for one VG | VG `unavailable` + `errors` entry; page still renders. | +| LVM capacity probe exceeds budget | VG `unknown` + `capacity_unknown`; page still renders. | +| Disk config missing/invalid/unsafe/unreadable | Status `200` with `config_missing`/`config_invalid`/`config_unsafe`/`config_unreadable`; config GET `200` with `disk_error_code`. | +| Running config cannot be marshaled | `GET /api/config`/status `500`; this is an internal invariant failure. | +| SQLite status read fails | `503`. | +| UI assets not built | `/` fallback `503`; API routes work. | +| Admin listener disabled | No port, no `.last-good`, gauge 0; existing behavior. | + +### Observability + +Structured `slog` lines: + +* admin listener start, failure, and stop; +* startup warning when the admin UI is enabled (loopback, no authentication, + root-equivalent); +* config save (path, client, config_changed, restart_required, reason, + warnings); +* config rejection (error, client); +* VG register/deregister (name, result); +* class set/remove (class, VG, result); +* audit write failure and last-good write failure. + +Never log the config body, credentials, or volume secrets. The existing +`observability.Metrics` gains `kouja_admin_ui_up` (1 serving, 0 disabled or +unavailable). No restart metrics. + +### Compatibility, rollout, and rollback + +* Configuration is additive. Existing files remain valid; the UI is disabled + by default. +* No schema migration. No protobuf, CSI, or mTLS API shape change. mTLS VG + deregistration now rejects a VG referenced by a class mapping, preventing a + dangling mapping; volume guards are unchanged. +* `InitializeVGs` is unchanged: first-start `EffectiveVGs()` seed, then + verification. Live VG and class changes are additive. +* Runtime is one binary. `make build` requires Node; `make build-go` does not. +* Rollout: enable `admin_ui`, restart once, open the URL. VG/class changes are + live after that. +* Rollback: disable `admin_ui`, remove the `admin_ui` section before running + an older binary (which uses `KnownFields(true)`), and restart. No database + migration to reverse. + +### Risks and constraints + +* Any local process that can reach the port is root-equivalent through the + config editor and live VG/class actions. SSRF-capable local services and + host-network containers are inside the trust boundary. Single-administrator + host precondition. +* The UI does not restart. Binding socket changes require an operator + `systemctl restart`; non-socket startup-bound fields may require an operator + restart as well, but the UI does not set `restart_required` for them; + preflight warnings may advise a restart. This is an accepted policy risk. +* `.last-good` is absent on first enablement and when disabled; recovery then + uses `.bak`, the installer starter, or a shell backup. +* Status caps are 256 volumes and 64 problem operations. Above that, the page + truncates and shows totals. +* nvmet session counts are unknown on the supported kernel; `active_sessions` + is null and `session_state` is `unknown`. `observed_initiator` is the + reconciler's stored ACL value (`initiator_source:"acl"`), not a kernel + session identity; it is null only when the observation value is absent. +* Observation freshness is bounded by the reconcile interval; the UI marks + stale observations. +* The Preact/Vite build requires Node for the full UI. `make build-go` is the + Node-free path. +* Class changes write the config file; a concurrent raw editor can create a + conflict, which the ETag/lock path detects. + +-------------------------------------------------------------------------------- + +## Alternatives Considered + +### Alternative A: Separate admin binary and static file server + +* **Pros**: Independent lifecycle and upgrade. +* **Cons**: Second binary, second config parser, second state/LVM path, larger + installer and systemd unit. It would still need root for config and LVM, so + it does not create a useful privilege boundary. +* **Reason for Rejection**: The requirement is one embedded UI in the agent. + +### Alternative B: Admin UI on the existing mTLS listener + +* **Pros**: One listener, existing certificate roles. +* **Cons**: Browser client certificates and enrollment; UI exposed on the + caller network. +* **Reason for Rejection**: The requirement is a separate loopback listener + with no application authentication. + +### Alternative C: Read-only config view + +* **Pros**: Smallest write surface. +* **Cons**: Administrator still needs a shell; does not meet the config goal. +* **Reason for Rejection**: Full YAML editing was selected. + +### Alternative D: Live subset editor + +* **Pros**: Common changes apply without restart. +* **Cons**: Only VGs have an existing live admin service; class mappings need + a new registry. The listener, TLS, LVM backend, targets, admission, store, + and reconciler are startup-bound. +* **Reason for Rejection**: The UI uses the existing live VG service plus a + new class registry and reports binding socket changes; it does not invent a + second config model for the remaining fields. + +### Alternative E: Unix socket or token boundary + +* **Pros**: Real local user boundary. +* **Cons**: SSH forwarding or a token; conflicts with the no-auth requirement. +* **Reason for Rejection**: Loopback TCP without auth was selected; the + single-administrator precondition is documented. + +### Alternative F: Vendored Preact with `htm`, no build + +* **Pros**: No Node, `go build` works. +* **Cons**: No TypeScript or standard Preact tooling. +* **Reason for Rejection**: Preact/Vite is a product constraint; the full UI + build keeps TypeScript checking. `make build-go` remains the Node-free + path. + +### Alternative G: Commit the Vite output + +* **Pros**: Node-free `go build`. +* **Cons**: Generated files in Git and drift. +* **Reason for Rejection**: One source of truth; the fallback and + `make build-go` cover Node-free builds. + +### Alternative H: Vanilla JavaScript + +* **Pros**: No framework or build. +* **Cons**: Manual DOM and asynchronous state handling; TypeScript checking + is lost. +* **Reason for Rejection**: Preact is the selected UI framework. + +### Alternative I: Restart from the UI + +* **Pros**: One-click apply. +* **Cons**: Root process restarts itself; broken config takes the UI down; + systemd/non-systemd behavior differs; requires exit-code and restart + tracking. +* **Reason for Rejection**: The operator restarts manually. The UI only + reports `restart_required` for binding socket changes. + +### Alternative J: Structured form editor + +* **Pros**: Guided fields and checks. +* **Cons**: Duplicates the config schema and lags new fields. +* **Reason for Rejection**: Raw YAML with server validation covers every + field; live VG/class controls cover the common administrative changes. + +-------------------------------------------------------------------------------- + +## Non-Functional Requirements + +The Technical Plan is canonical for behavior and failure handling; this section +states the non-functional targets and constraints. + +### Security and privacy + +* Loopback-only listener; Host/Origin/`Sec-Fetch-Site` and content-type + checks; no CORS; strict CSP; 1 MiB limits. +* No authentication. Any local process that can reach the port is + root-equivalent through the config editor and live VG/class actions. The + single-administrator precondition is a deployment requirement, not a + technical control. +* Config, backup, last-good, and lock files are `0600`; symlinks are rejected + by the writer. +* Status/config responses exclude credentials, grants, and the secret key. +* SSRF-capable local services and host-network containers are inside the trust + boundary; do not expose the port via a proxy or port forward. + +### Performance and latency + +* Status uses one SQLite transaction, at most 256 volumes, 64 problem + operations, and one capacity probe per VG with a 10-second total budget, a + 3-second per-VG timeout, and concurrency 4. No cache. A single in-flight + status request is coalesced. +* Config saves parse once, write `.bak` and the new file, and fsync. The body + is 1 MiB. `.last-good` is one startup write; saves and class writes do not + refresh it. +* VG/class changes are one state transaction plus, for classes, one config + write and one live map swap. +* The embedded UI is a one-time binary-size cost; no runtime file reads or + CDN. + +### Reliability and recovery + +* Invalid config is rejected before writing. +* `.bak` failure aborts the save. A post-rename directory-fsync failure is + reported as `durability_uncertain` with `.bak` recovery. +* `.last-good` is written when the UI is enabled and readiness is reached; + saves and class writes do not refresh it. First enablement has no recovery + file. +* Admin listener failures are non-fatal. Admin `Serve` errors call + `adminServer.Shutdown`, not the process stop callback. +* The UI never restarts, exits, or executes systemctl. Binding socket changes + set `restart_required`; the operator restarts manually. +* VG deregistration with volumes is rejected. Class changes validate the VG + and persist before the live map changes. + +### Compatibility and rollout + +* Additive config; UI disabled by default. No schema migration, no protobuf, + CSI, or mTLS API shape change. The class-mapped VG deregistration rejection + is an intentional behavior change. +* `InitializeVGs` remains first-start `EffectiveVGs()` seed plus + verification. +* Runtime single binary. `make build` needs Node; `make build-go` does not. +* Rollout: enable, restart once, use the UI. VG/class changes are live. +* Rollback: disable, remove `admin_ui` before an older binary (KnownFields), + restart. No database migration to restore. + +## Additional Resources + +* `docs/designs/kouja.md`: existing architecture, state model, API, and + failure behavior. +* `README.md`: install, build, API, and operations guidance. +* `proto/kouja/v1`: existing mTLS API contract. +* `internal/state/schema.go`: volume, export, observation, operation, and + audit tables. +* `internal/agent/server.go`: listener, TLS, and shutdown patterns. +* `internal/agent/admin_service.go`: existing VG/admin operations. +* Preact and Vite documentation for the build setup. + +## Future Improvements + +* **NVMe/TCP traffic counters**: Add equivalent nvmet per-session or + per-subsystem statistics if the kernel exposes them. +* **Config diff preview**: Show a parsed difference before saving. +* **Frontend tests**: Add component or browser tests. +* **Automatic class persistence alternatives**: If structured YAML edits + become too complex, store class mappings in SQLite in a future schema. +* **Authentication**: Add a local token or root-only Unix socket if the host + stops being single-administrator. + +-------------------------------------------------------------------------------- + +## Detailed Implementation Plan + +The order below is the recommended implementation order. Every file created or +modified is listed. Phase checkpoints are included. + +### Target Files to Create/Modify + +**Phase 0: Live iSCSI metrics** + +#### 0a. `internal/target/iscsi_metrics.go` (Create) + +* **Type / Scope**: Read-only LIO statistics. +* **Proposed Changes**: Add `ISCSIMetricsProvider`, `ISCSIMetrics`, + `ISCSITargetMetrics`, initiator/LUN/error types, and + `(*ISCSI).Metrics(context.Context)`. Read `target/iscsi/*/fabric_statistics`, + per-ACL `iscsi_sess_stats`, LUN `statistics/scsi_lu`, `scsi_tgt_dev`, and + `scsi_tgt_port`. Read target portal ports from `np`, active client IPs from + ACL `info`, and established TCP endpoints from `/proc/net/tcp(6)`. Missing + optional attributes are zero; target/LUN parse errors are recorded in the + corresponding `Error` field. The method never mutates target state and a + missing `target/iscsi` tree is an empty success. + +#### 0b. `internal/target/iscsi_metrics_test.go` (Create) + +* **Type / Scope**: Go unit tests. +* **Proposed Changes**: Test full target/ACL/LUN parsing, aggregate reset and + abort counters, empty target tree, cancelled context, invalid stat values, + LUN index parsing, missing optional fields, `/proc/net/tcp` IPv4/IPv6 + parsing, portal-port filtering, and client-to-target matching. + +**Phase 1: Configuration and shared helpers** + +#### 1. `internal/config/config.go` (Modify) + +* **Type / Scope**: Go configuration schema and validation. +* **Proposed Changes**: Add `AdminUI AdminUIConfig` with tag + `yaml:"admin_ui"`, and `AdminUIConfig{Enabled bool yaml:"enabled"; Address + string yaml:"address"}`. Defaults: `enabled:false`, + `address:127.0.0.1:9444`. `applyDefaults` fills an empty address. `Validate` + parses the address with `net.SplitHostPort`, accepts only exact host + `127.0.0.1` or `::1`, rejects hostnames and the rest of `127.0.0.0/8`, and + requires numeric port 1-65535. Validate even when disabled. Add + `func LoadWithData(path string) (*Config, []byte, error)` retaining `Load`'s + empty-path check and `Parse`; `Load` wraps it and discards bytes. + +#### 2. `internal/config/config_test.go` (Modify) + +* **Type / Scope**: Go unit tests. +* **Proposed Changes**: Test disabled default, default address, + `127.0.0.1:9444`/`[::1]:9444` acceptance, rejection of `0.0.0.0`, `127.0.0.2`, + `localhost`, port 0, out-of-range and named ports, unknown `admin_ui` field, + validation while disabled, and `LoadWithData` byte fidelity. Add named + `bddTestAdminUIConfig` and `bddTestLoadWithData` functions. + +#### 3. `internal/config/ginkgo_suite_test.go` (Modify) + +* **Type / Scope**: Ginkgo suite registration. +* **Proposed Changes**: Register `TestAdminUIConfig` and + `TestLoadWithData`. + +#### 4. `internal/config/storage_class.go` (Create) + +* **Type / Scope**: Shared helper. +* **Proposed Changes**: Add + `func StorageClassForVG(classes map[string]string, vg string) string`. + +#### 5. `internal/config/storage_class_test.go` (Modify) + +* **Type / Scope**: Go unit tests. +* **Proposed Changes**: Test mapped class, raw VG fallback, nil/empty map. + +#### 6. `internal/config/storage.go` (Create) + +* **Type / Scope**: Structured config updater for live class changes. +* **Proposed Changes**: Add + `func SetClass(path, class, vg string) (warnings []string, err error)` and + `func RemoveClass(path, class string) (warnings []string, err error)`. + Acquire `.lock` with `unix.Flock` and a 2-second timeout + (`ErrLocked` on timeout), read the file into a `yaml.Node`, navigate/create + `storage.classes`, set/delete the key, marshal, and write through + `internal/fsutil.WriteFileAtomic` with mode `0600`. Preserve comments and + unrelated keys. Before the final write, copy the current bytes to + `.bak` atomically; if the backup fails, abort with the live class map + unchanged. Validate the marshaled result with `config.Parse` before the + write; reject symlinks and non-regular files (`ErrUnsafePath`). Define + `ErrLocked`, `ErrClassNotFound`, and `ErrUnsafePath` sentinels. A + post-rename directory-sync failure is a successful write with a + `durability_uncertain` warning, not an error. `.bak` always holds the + immediately previous bytes, including after a class write; `.last-good` is + not refreshed. Add tests for add, replace, remove, missing class, missing + storage section, comment preservation, lock conflict, backup failure, + durability warning, and atomic failure. + +#### 6a. `internal/fsutil/atomic.go` (Create) + +* **Type / Scope**: Atomic file writer. +* **Proposed Changes**: Add + `func WriteFileAtomic(path string, data []byte, mode fs.FileMode) error`. + It creates a temp file in the target directory, applies the mode, writes, + syncs, closes, renames, and syncs the directory. It removes the temp file on + failure. Define `var ErrDurabilityUncertain = errors.New(...)` and return + it only when the rename succeeded but the directory sync failed; callers + treat that as success with a warning. The admin config writer, `.bak`, + `.last-good`, and structured class updater use it. The existing + hostid/credential/backup writers are not changed; migrating them is a + future cleanup. + +#### 6b. `internal/fsutil/atomic_test.go` (Create) + +* **Type / Scope**: Go unit tests. +* **Proposed Changes**: Test success, mode `0600`, replacement, cleanup on + temp-write failure, rename failure with a target that is a non-empty + directory (deterministic even as root), post-rename directory-sync + uncertainty where the file is visible and `ErrDurabilityUncertain` is + returned, and original-file behavior. This package uses plain Go tests; it + has no shared Ginkgo test infrastructure. + +#### 6c. `internal/config/storage_test.go` (Create) + +* **Type / Scope**: Go unit tests. +* **Proposed Changes**: Test set/replace/remove class, missing class, + missing `storage.classes`, comment preservation, invalid YAML rejection, + `.bak` creation, `.last-good` unchanged, lock timeout, durability warning, + and atomic write failure. Use temporary files. + +**Phase 2: Live VG and class services** + +#### 7. `internal/agent/vg_service.go` (Create) + +* **Type / Scope**: Shared VG service. +* **Proposed Changes**: Define: + ```go + type VGService interface { + Register(ctx context.Context, name, expectedUUID string, replace bool) (lvm.Capacity, error) + Deregister(ctx context.Context, name string) error + List(ctx context.Context) ([]state.VG, error) + Inspect(ctx context.Context, name string) (lvm.Capacity, error) + } + type VGManager struct { store *state.Store; lvm lvm.Backend; classes ClassRegistry } + func NewVGManager(store *state.Store, lvmBackend lvm.Backend, classes ClassRegistry) *VGManager + ``` + `Inspect` runs `Eligibility` and `Capacity` without writing state; preflight + uses it. `Register` validates the name, calls `Inspect`, checks a non-empty + `expectedUUID` against the observed UUID before any row logic + (`ErrVGUUIDMismatch`), then applies the create-only/replace rule: absence + inserts; same UUID is idempotent and refreshes `updated_at`; a different + UUID with `replace=false` is `ErrVGConflict`; a different UUID with + `replace=true` updates, and an empty `expectedUUID` skips the check to + preserve the legacy upsert. `Deregister` checks classes + (`ErrVGClassMapped`) + and live volumes (`ErrVGInUse`) before `tx.DeleteVG`. `List` uses + `tx.ListVGs`. Define sentinel errors `ErrVGInUse`, `ErrVGUnavailable`, + `ErrVGConflict`, `ErrVGUUIDMismatch`, `ErrVGNotFound`, and + `ErrVGClassMapped`. + +#### 8. `internal/agent/vg_service_test.go` (Create) + +* **Type / Scope**: Go unit tests. +* **Proposed Changes**: Test register success, idempotent same-UUID + `updated_at` refresh, create-only duplicate conflict, new-row UUID + mismatch, same-UUID row UUID mismatch, empty-UUID replace preserving the + legacy upsert, replace/UUID match/mismatch, ineligible VG, read-only + `Inspect`, deregister empty, deregister with volume, deregister with a + class mapping, list, and gRPC/HTTP error mapping. Use the existing state + test store, a fake LVM backend, and a fake class registry. + +#### 9. `internal/agent/admin_service.go` (Modify) + +* **Type / Scope**: mTLS AdminService. +* **Proposed Changes**: Replace the inline RegisterVG/DeregisterVG logic with + calls to `VGService.Register`/`Deregister`; pass + `request.GetVgUuid()` as `expectedUUID` and `replace=true` to preserve the + existing upsert/UUID check. Map `ErrVGUUIDMismatch`, `ErrVGInUse`, + `ErrVGUnavailable`, and `ErrVGClassMapped` to `FailedPrecondition`; + `ErrVGNotFound` to `NotFound`; `ErrVGConflict` to `AlreadyExists`. Use + `VGService.List` for `ListVGs` state rows and keep the existing + capacity/class loop. Keep the existing auth checks, request validation, + response protos, and storage-class labels. Add `WithVGService(VGService)` + and `WithVGClassGate(*sync.Mutex)` to the admin options; `DeregisterVG` + holds the gate across the class/volume guard and delete. Construct from + main. `WithStorageClasses` is replaced by + `WithClassRegistry(ClassRegistry)`. Default `VGService` to + `NewVGManager(store, lvmBackend, classes)` when no option is supplied; a nil + `ClassRegistry` falls back to raw VG labels. + +#### 10. `internal/agent/class_registry.go` (Create) + +* **Type / Scope**: Live class registry. +* **Proposed Changes**: Define: + ```go + type ClassRegistry interface { + Snapshot() map[string]string + ClassName(vg string) string + Resolve(class, vg string) (string, string, error) + Set(ctx context.Context, class, vg string) (warnings []string, err error) + Remove(ctx context.Context, class string) (warnings []string, err error) + } + type ClassMap struct { mu sync.RWMutex; classes map[string]string; configPath string } + func NewClassMap(classes map[string]string, configPath string) *ClassMap + ``` + Initialize from the map. `Snapshot`/`ClassName`/`Resolve` take `RLock`; + `Set`/`Remove` hold the write lock across + `config.SetClass`/`RemoveClass` and the map swap; the config updater takes + the file lock inside. The binding `VGClassGate` (held by the local handler + and `AdminService` deregistration) is outside the class lock; the lock order + is `VGClassGate` -> `ClassMap.mu` -> `.lock`, never the reverse. + `Set` validates class-name and duplicate-VG rules; a VG already used by + another class returns the agent `ErrClassConflict`, which the handler maps + to `409`/`class_conflict`; define that sentinel in `class_registry.go`. + The handler validates VG registration before calling. A successful write + does not refresh `.last-good` and returns any `durability_uncertain` + warning. `Remove` returns `config.ErrClassNotFound` when absent. `Resolve` mirrors + the existing `resolveStorageClass`; `ClassName` mirrors + `storageClassForVG`. The local handler and tests cover `Set`/`Remove`, + duplicate class, unknown VG at the handler boundary, config write failure + (map unchanged), concurrent `Set`/`Remove`, and concurrent snapshots. + +#### 10a. `internal/agent/class_registry_test.go` (Create) + +* **Type / Scope**: Class registry tests. +* **Proposed Changes**: Use a temporary config file. Test set/remove/ + persistence, duplicate class, missing class (`ErrClassNotFound`), config + write failure leaving the map unchanged, durability warning, raw VG + fallback, interleaved `Set`/`Remove`, and concurrent reads during a set. The + unknown-VG check lives in the handler test. Register with + `TestClassRegistry`. + +#### 11. `internal/agent/volume_service.go` (Modify) + +* **Type / Scope**: Volume service. +* **Proposed Changes**: Replace `ServiceOptions.StorageClasses + map[string]string` with `Classes ClassRegistry`. Use `Classes.Resolve` in + `CreateVolume` only; `ExportVolume` has no class request and uses + `Classes.ClassName(volume.VGName)` for response labels. Use + `Classes.ClassName` in `volumeProto` and `capacityProto`. If `Classes` is + nil, class equals VG (the existing raw-VG behavior). Keep the mTLS behavior + identical when the registry is initialized from the same maps. Add a test + that a live class change relabels a new export response without changing the + volume's VG. + +#### 12. `internal/agent/storage_class.go` (Modify) + +* **Type / Scope**: Compatibility wrappers. +* **Proposed Changes**: Keep `storageClassForVG` and `resolveStorageClass` as + wrappers that use `config.StorageClassForVG` or accept a class map for + existing tests. The live services use `ClassRegistry`; do not change the + mTLS API semantics. + +#### 12a. `internal/agent/admin_service_test.go` (Modify) + +* **Type / Scope**: Existing admin tests. +* **Proposed Changes**: Update construction to provide a `VGService` (default + `NewVGManager(store, backend, classes)`) and a class registry or nil + fallback. Keep + the auth, grant, drift, and VG tests. Add a test that `RegisterVG` and + `DeregisterVG` delegate to the shared service. + +#### 12b. `internal/agent/volume_service_test.go` (Modify) + +* **Type / Scope**: Existing volume tests. +* **Proposed Changes**: Update `ServiceOptions` to use `Classes` when a test + needs class mappings. A nil `Classes` behaves as an empty map (class equals + VG), so tests without classes remain valid. Keep behavior assertions. + +#### 12c. `internal/agent/storage_class_test.go` (Modify) + +* **Type / Scope**: Class tests. +* **Proposed Changes**: Replace `ServiceOptions{StorageClasses: ...}` with a + live `ClassRegistry` test double or a `ClassMap` built from the map. Test + `ListStorageClasses`, `CreateVolume` class resolution, and raw VG fallback. + +#### 12d. `internal/agent/ginkgo_suite_test.go` (Modify) + +* **Type / Scope**: Suite registration. +* **Proposed Changes**: Register `TestVGService` and + `TestClassRegistry` from the new test files. + +**Phase 3: State read model** + +#### 13. `internal/state/types.go` (Modify) + +* **Type / Scope**: State types. +* **Proposed Changes**: Add + `VolumeStatus{Volume; Export *Export; Observation *Observation; ExportObservation *ExportObservation}`. + +#### 14. `internal/state/store.go` (Modify) + +* **Type / Scope**: Tx interface. +* **Proposed Changes**: Add `ListVolumeStatuses(limit int)`, + `VolumeStateCounts()`, `ListRecentOperations(limit int)`, and + `OperationCount()`. No `schemaVersion` change. `Tx.ListVGs` remains the VG + read method. + +#### 15. `internal/state/status.go` (Create) + +* **Type / Scope**: Status queries. +* **Proposed Changes**: Implement the four methods. `ListVolumeStatuses` + joins exports/volume_observations/export_observations on owner and volume_id, + selects all volume columns plus explicit aliases. Volume columns use the + same nullable handling as the existing `scanVolume` helper (or reuse it), + including `actual_size_bytes`, `last_operation_id`, `error_code`, + `drift_reasons`, and `legacy_identity`, which may be NULL. Every joined + column is scanned through `sql.NullString`/`sql.NullInt64`/`sql.NullBool` + (or equivalent) because the LEFT JOIN produces NULL for a missing related + row; construct the `Export`/`Observation`/`ExportObservation` pointers only + when the related row exists (for example `exports.owner IS NOT NULL`), + decode + `drift_reasons` and `legacy_identity` with + `decodeStrings`/`decodeMap`, normalizes `limit<=0` to 256 and caps at 256, + and orders by `updated_at DESC, owner, volume_id`. `VolumeStateCounts` + groups by state. `ListRecentOperations` filters + `running|failed|needs_attention`, normalizes/caps at 64, orders + `updated_at DESC, id DESC`. `OperationCount` counts the same set. No new + indexes. + +#### 16. `internal/state/status_test.go` (Create) + +* **Type / Scope**: Go unit tests. +* **Proposed Changes**: Test joined rows with export/observations, no related + rows, NULL `actual_size_bytes`/`last_operation_id`/`error_code`, + drift_reasons/legacy_identity round-trip, multiple owners with duplicate + volume IDs (manual inserts with distinct resource UUID/LV/updated time, not + `addVolume`), ordering, limits 0/negative/over-cap, state counts, + problem-operation filter, operation count. Use `openTestStore` only where + it can create the current schema; no migration test. + +#### 17. `internal/state/ginkgo_suite_test.go` (Modify) + +* **Type / Scope**: Suite registration. +* **Proposed Changes**: Register `TestVolumeStatusQueries` calling + `bddTestVolumeStatusQueries`. + +**Phase 4: Admin UI backend** + +#### 18. `internal/adminui/server.go` (Create) + +* **Type / Scope**: HTTP server, routes, middleware, handlers. +* **Proposed Changes**: Implement the package API below. Routes: + `GET /`, `GET /assets/`, `GET /favicon.ico`, `GET /api/status`, + `GET /api/config`, `PUT /api/config`, `GET/POST /api/vgs`, + `DELETE /api/vgs/{name}`, `GET /api/classes`, + `PUT/DELETE /api/classes/{name}`, `POST /api/targets/disable`, + `POST /api/targets/enable`. Methods: + ```go + func New(options Options) (*Server, error) + func (server *Server) Handler() http.Handler + func (server *Server) Serve() error + func (server *Server) Shutdown(ctx context.Context) error + func (server *Server) Address() net.Addr + func (server *Server) MarkRunning() error + ``` + `Options{ConfigPath, Config, ConfigData, Store, Capacity, VGs, Classes, + Ready, HostUUID, AgentVersion, APIVersion, StartedAt, Logger, Now, Assets, + Listener, Metrics, TargetMetrics, DisableTarget, EnableTarget, VGClassGate}`. `New` validates nil dependencies, a non-empty + `ConfigPath`, disabled state, binds loopback, verifies the address, stores + the parsed startup configuration, and derives the allowed Host set from the + actual listener port (so injected ephemeral listeners work). Middleware + order: recover, security headers, Host guard, route; mutations also check + Origin, `Sec-Fetch-Site`, and content type. Handlers: `handleStatus`, + `handleGetConfig`, `handlePutConfig`, `handleListVGs`, `handleRegisterVG`, + `handleDeregisterVG`, `handleListClasses`, `handleSetClass`, + `handleRemoveClass`. VG/class mutation handlers hold `options.VGClassGate` + across the registration/class check and the mutation; `AdminService` + deregistration takes the same gate. The save sequence is + `server.saveConfig(ctx, body, ifMatch, ifNoneMatch, remote)`. `Server` + state is guarded by a mutex: `lastGoodError`, `serving`, `inFlight` status, + and `allowedHosts`. `Serve` sets the gauge 1; `Shutdown` sets 0. + +#### 19. `internal/adminui/types.go` (Create) + +* **Type / Scope**: JSON wire types. +* **Proposed Changes**: Define `StatusResponse`, `AgentStatus`, `ISCSIInfo`, `VGStatus`, + `VolumeStatus`, `ExportView`, `ObservationView`, `ExportObservationView`, + `OperationView`, `Counts`, `ErrorView`, `MetricsView`, `TargetMetricsView`, + `InitiatorMetricsView`, `LUNMetricsView`, `MetricsErrorsView`, + `ConfigResponse`, `TLSInfo`, + `SaveConfigResponse`, `VGListResponse`, `RegisterVGResponse`, + `ClassListResponse`, `SetClassResponse`, `CertificateFile`, + `CertificatesResponse`, `WarningView{Code,Message}`, and + `APIError{Error,Code}`. Fields match the Technical Plan exactly. All slices + are initialized non-nil and all maps (including `classes` and + `counts.by_state`) are initialized to `{}`, never `null`. Time fields are + RFC3339Nano UTC strings. `counts.by_state` uses the exact known states. + `active_sessions` is `*int32`; `observed_initiator` is `*string`. + +#### 20. `internal/adminui/status.go` (Create) + +* **Type / Scope**: Status assembly. +* **Proposed Changes**: `buildStatus(ctx)` uses one `Store.Transact` with + `ListVGs`, `ListVolumeStatuses(256)`, `VolumeStateCounts`, + `ListRecentOperations(64)`, `OperationCount`. Seed `counts.by_state` with + zero for all nine volume states before merging `VolumeStateCounts`. Build + the effective running config with `Storage.Classes = Classes.Snapshot()` and merge its + `EffectiveVGs()` with registered VGs. Probe capacity with a 10s total + budget, 3s per VG, concurrency 4. Call shared `buildVGStatus` for status, + `GET /api/vgs`, and `POST /api/vgs`; use capacity `VGState`/`VGUUID` for + display, mark budget-exceeded probes `unknown`, and surface UUID mismatch + or probe error as `unavailable` with an `ErrorView`. Compute + `config_changed/restart_required/reason` via `configState()` and append the + matching `config_missing`/`config_invalid`/`config_unsafe`/ + `config_unreadable` entry for unavailable disk config. + Compute `serving` (false when observations are missing or stale), `stale` + (2x `state.reconcile_interval_seconds`), stored ACL observed initiator, + counts, and truncation. Return `503` on store failure and `500` if the + effective running configuration cannot be marshaled. Use a non-blocking + in-flight guard. + +#### 20a. `internal/adminui/metrics.go` (Create) + +* **Type / Scope**: Status metrics view and rate sampler. +* **Proposed Changes**: Add `buildMetrics(ctx, volumes) MetricsView`, + target-to-volume joining, conversion to wire views, cumulative error + counts, and per-target read/write/command rates from the previous successful + sample. Store the last sample on `Server` under `metricsMu`; samples closer + than one second do not replace the baseline. A provider error sets + `available:false` and `error` without failing status. + +#### 21. `internal/adminui/config_file.go` (Create) + +* **Type / Scope**: Config read/save and state comparison. +* **Proposed Changes**: Define: + ```go + type Warning struct { Code, Message string } + type configFile struct { Raw []byte; Parsed *config.Config; ETag string; DiskError string; DiskErrorCode string; Valid bool } + type ConfigState struct { + DiskYAML, RunningYAML, DiskETag string + DiskValid bool + DiskError, DiskErrorCode string + ConfigChanged, RestartRequired bool + RestartReason, LastGoodError string + Warnings []Warning + } + type SaveResult struct { + Saved bool + Path, BackupPath, DiskETag string + ConfigChanged, RestartRequired bool + RestartReason string + Warnings []Warning + } + func readConfig(path string) (configFile, error) + func configEquivalent(running, candidate *config.Config, classes agent.ClassRegistry) bool + func rawETag(data []byte) string + func bindingSocketChanged(running, candidate *config.Config) (bool, string) + func (server *Server) configState() (ConfigState, error) + func (server *Server) saveConfig(ctx context.Context, body []byte, ifMatch, ifNoneMatch, remote string) (SaveResult, error) + func preflight(ctx context.Context, candidate, running *config.Config, vgs agent.VGService) []Warning + func configPaths(path string) (backup, lastGood, lock string) + ``` + `readConfig` returns the display model for missing/parse_error/unsafe_path/ + read_error. `configState` uses `server.options.Config`, `ConfigPath`, + `Classes`, and the mutex-guarded `lastGoodError`; it returns a `ConfigState` + with `ConfigChanged:true`, `DiskValid:false`, and `DiskErrorCode` for disk + problems; it returns an error only for a running-config marshal failure. + `saveConfig` runs preflight (5-second bounded context) before acquiring the + lock, then does lock/recheck/backup/atomic-write, releases the lock, and + only then computes `config_changed`/`config_equivalent` and writes the + audit/response. It returns the new ETag, `config_changed`, + `restart_required`, reason, and warnings; no restart. `bindingSocketChanged` compares the exact fields listed in the + Technical Plan. `configEquivalent` sorts/deduplicates `storage.vgs` and + uses the live class map; portal order is meaningful for `config_changed`. + `preflight` uses `VGs.Inspect` (read-only), skips bind probes for unchanged + addresses, and applies a 5-second total context. `writeAtomic` uses + `internal/fsutil.WriteFileAtomic`; `ErrDurabilityUncertain` becomes a + `durability_uncertain` warning. + +#### 22. `internal/adminui/audit.go` (Create) + +* **Type / Scope**: Audit writer. +* **Proposed Changes**: Implement + `writeAudit(ctx context.Context, store Store, action, operationID, preState, postState, result, errorCode, remote string) error`. + It creates a UUID if `operationID` is empty, calls `store.Transact` with + `tx.PutAudit(state.Audit{...})`, sets `caller:"admin-ui"`, `source_ip` as + the host part, and writes the supplied fields; `preState`/`postState` map + to `state.Audit.PreState`/`PostState` (no hash columns exist). Used for + `config_save`, `vg_register`, `vg_deregister`, `class_set`, `class_remove`. + No restart action. A 204 delete cannot carry a warning: log audit failure + only. A 200/201 mutation includes `warnings` in its JSON body. + +#### 23. `internal/adminui/vg.go` (Create) + +* **Type / Scope**: VG/class handlers. +* **Proposed Changes**: Implement `handleListVGs`, `handleRegisterVG`, + `handleDeregisterVG`, `handleListClasses`, `handleSetClass`, + `handleRemoveClass`. Validate JSON, class/VG names, and content type. Map + errors: invalid `400`/`422`, duplicate/conflict/lock `409`, in-use/class- + mapped `409`, unavailable `422`, not-found `404`, internal `500`. Hold + `options.VGClassGate` across each VG/class check and mutation. VG register + calls `VGs.Register(ctx, name, "", false)` and returns `201` with + `{"vg": buildVGStatus(...), "warnings": [...]}`. VG deregister/class remove + return `204`. Class set checks the VG is registered (`VGs.List`), calls + `Classes.Set`, recomputes `config_changed`/`restart_required` via + `configState`, and returns `200` with the live map and warnings. Class + remove calls `Classes.Remove`; a missing class is `404`. + Write audit rows after successful mutations; map warning codes + (`durability_uncertain`, `audit_write_failed`) to `WarningView` messages. + Include them in 200/201 bodies; 204 deletes log only. No restart. + +#### 24. `internal/adminui/assets.go` (Create) + +* **Type / Scope**: Embedded assets. +* **Proposed Changes**: `//go:embed all:assets` for the generated output and + `//go:embed fallback.html` separately. `Options.Assets` is the resolved + output root by default (`fs.Sub(embeddedAssets, "assets")`); injected assets + are used as the resolved root. Serve `index.html` at `/`, hashed files under + `/assets/`, reject directory/dot/`..` paths, set exact cache headers, serve + fallback `503` when `index.html` is missing. + +#### 25. `internal/adminui/fallback.html` (Create) + +* **Type / Scope**: Static fallback. +* **Proposed Changes**: Plain HTML with the `make build` instruction; no + script, external asset, or inline event handler. + +#### 26. `internal/adminui/assets/.gitkeep` (Create) + +* **Type / Scope**: Placeholder. + +#### 27. `internal/adminui/assets/.gitignore` (Create) + +* **Type / Scope**: Generated-asset ignore. +* **Proposed Changes**: Ignore everything except `.gitkeep` and the ignore + file. + +#### 28. `internal/adminui/ginkgo_suite_test.go` (Create) + +* **Type / Scope**: Suite. +* **Proposed Changes**: Register `TestAdminUIHandlers`, + `TestAdminUIConfigFile`, `TestAdminUIStatus`, `TestAdminUIVGClasses`, and + `TestAdminUIAudit`. + +#### 29. `internal/adminui/server_test.go` (Create) + +* **Type / Scope**: HTTP tests. +* **Proposed Changes**: Test Host, Origin, `Sec-Fetch-Site`, content types, + body limit, status/error codes, CSP/cache headers, fallback/assets, + `/favicon.ico` 204, unknown route 404, wrong method 405, panic recovery, + empty `ConfigPath`, injected ephemeral-listener Host validation, and that + no restart route exists. Add a `-race` test with concurrent status requests + plus `MarkRunning`/`Shutdown` to cover the shared server state. Main wiring + is covered by `main_test.go` and the manual host check. + +#### 30. `internal/adminui/config_file_test.go` (Create) + +* **Type / Scope**: Config file tests. +* **Proposed Changes**: Test regular/missing/invalid/symlink/non-regular/ + unreadable read models and codes; `configState` returns disk errors without + a Go error; ETag formats; `If-Match` and `If-None-Match`; `.bak` + success/failure; `.last-good`; `.lock` timeout; directory-fsync warning; two + sequential saves with new ETag; comment-only and VG-order saves set no + `restart_required`; a pure target-portal reorder sets `config_changed` but + no `restart_required`, with a warning; binding socket changes set + `restart_required` with deterministic reasons; preflight runs before the + lock (stalled LVM does not block a class update), skips unchanged + addresses, checks changed addresses, VG eligibility via `Inspect`, secret + key changes, and non-blocking warnings; non-cooperating writer hook before + final recheck returns `409`; concurrent save + class `Set` completes without + a lock-order deadlock (timeout guard). + +#### 31. `internal/adminui/status_test.go` (Create) + +* **Type / Scope**: Status tests. +* **Proposed Changes**: Assert exact JSON fields, arrays never null, serving + true/false including `lv_present` and stale/missing observations, active + sessions null when unknown, stored ACL observed initiator with + `initiator_source:"acl"`, configured VGs recomputed from the live class map, + capacity budget-exceeded `unknown`, capacity error `unavailable`, UUID + mismatch, `config_missing`/`config_invalid` for disk config, counts/truncation + at 256/64, `503` on store failure, `500` on running-config marshal failure, + and no cache (two requests see a changed row). + +#### 32. `internal/adminui/vg_test.go` (Create) + +* **Type / Scope**: VG/class handler tests. +* **Proposed Changes**: Test register 201, same-UUID idempotent 201, + different-UUID 409, ineligible 422, `201` body `{"vg":...,"warnings":[]}`, + deregister 204, in-use 409, class-mapped 409, list via `buildVGStatus`, + class set/remove, unknown VG 422, duplicate class 409, missing class 404, + config write failure leaves registry unchanged, lock timeout 409, audit rows + for each mutation, 204 audit failure log-only, and concurrent VG/class + mutations under `VGClassGate`. + +#### 33. `internal/adminui/audit_test.go` (Create) + +* **Type / Scope**: Audit tests. +* **Proposed Changes**: Test fields, UUID, source host, error code, and + audit failure warning. + +**Phase 5: Preact frontend** + +#### 34. `web/package.json` (Create) + +* **Type / Scope**: npm manifest. +* **Proposed Changes**: `kouja-admin-ui`, private, ESM. Scripts `dev: vite`, + `build: tsc --noEmit && vite build`, `typecheck: tsc --noEmit`, + `preview: vite preview`. Exact `preact@11.2.0`, `vite@8.3.6`, + `typescript@7.0.2`, `@preact/preset-vite@2.10.6`. No router/UI kit. + +#### 35. `web/package-lock.json` (Create) + +* **Type / Scope**: Lockfile. + +#### 36. `web/tsconfig.json` (Create) + +* **Type / Scope**: TypeScript config. +* **Proposed Changes**: `target: "ES2022"`, `module: "ESNext"`, + `moduleResolution: "Bundler"`, `lib: ["ES2022","DOM","DOM.Iterable"]`, + `jsx: "react-jsx"`, `jsxImportSource: "preact"`, `strict: true`, + `noEmit: true`, `types: ["vite/client"]`; include `src` and + `vite.config.ts`. + +#### 37. `web/vite.config.ts` (Create) + +* **Type / Scope**: Vite config. +* **Proposed Changes**: Preact preset; `base:"/"`; `build.target:"es2022"`; + `build.modulePreload.polyfill:false`; `build.outDir:"../internal/adminui/assets"`; + `build.emptyOutDir:false`; dev proxy `/api` to `http://127.0.0.1:9444`, + `changeOrigin:true`, `configure` rewrites `Origin` to + `http://127.0.0.1:9444`. + +#### 38. `web/index.html` (Create) + +* **Type / Scope**: Vite entry. +* **Proposed Changes**: Root element `id="app"` and + ``. No inline script or + external asset. + +#### 39. `web/.nvmrc` (Create) + +* **Type / Scope**: Node version. +* **Proposed Changes**: `22.12.0`. + +#### 40. `web/src/main.tsx` (Create) + +* **Type / Scope**: Entry. +* **Proposed Changes**: `render(, document.getElementById("app")!)`; + import `style.css`. + +#### 41. `web/src/api.ts` (Create) + +* **Type / Scope**: API client. +* **Proposed Changes**: `getStatus(signal?)`, `getConfig(signal?)`, + `saveConfig(yaml, etag, ifNoneMatch)`, `getVGs()`, `registerVG(name)`, + `deregisterVG(name)`, `getClasses()`, `setClass(name, vg)`, + `removeClass(name)`. Config save uses `If-Match` for an existing file and + `If-None-Match: *` for a missing file, `application/yaml`; other mutations + use `application/json`. Import `ApiError{status,code,message}` from + `types.ts`; do not redeclare it. Caller owns `AbortController`. + +#### 42. `web/src/types.ts` (Create) + +* **Type / Scope**: Types. +* **Proposed Changes**: Export `StatusResponse`, `AgentStatus`, `ISCSIInfo`, `VGStatus`, + `VolumeStatus`, `ExportView`, `ObservationView`, + `ExportObservationView`, `OperationView`, `Counts`, `ErrorView`, + `MetricsView`, `TargetMetricsView`, `InitiatorMetricsView`, + `LUNMetricsView`, `MetricsErrorsView`, `ClientConnectionView`, + `ConfigResponse`, `TLSInfo`, `SaveConfigResponse`, `VGListResponse`, + `RegisterVGResponse`, `ClassListResponse`, `SetClassResponse`, + `CertificateFile`, `CertificatesResponse`, `WarningView`, `ApiError`. Match the Technical Plan JSON exactly. Define + `ApiError` only here; `api.ts` imports it. Define the component callback + interfaces here: + ```ts + export interface StatusPageProps { + status: StatusResponse | null; + loading: boolean; + error: string; + onRefresh: () => void; + } + export interface ConfigPageProps { + config: ConfigResponse; + vgs: VGStatus[]; + classes: Record; + onSaved: (result: SaveConfigResponse) => void; + onDirtyChange: (dirty: boolean) => void; + onRefreshVGs: () => Promise; + onReloadConfig: () => Promise; + } + export interface VgsPanelProps { + vgs: VGStatus[]; + classes: Record; + classActionsDisabled: boolean; + onRegister: (name: string) => Promise; + onDeregister: (name: string) => Promise; + onSetClass: (name: string, vg: string) => Promise; + onRemoveClass: (name: string) => Promise; + } + ``` + Export these interfaces and import them in `app.tsx` and the components. + `ConfigPage` calls `onReloadConfig` after a class action and passes + `classActionsDisabled={dirty}` to `VgsPanel`. `onSaved` receives the save + response; `onDirtyChange` feeds the `beforeunload` guard. + +#### 43. `web/src/format.ts` (Create) + +* **Type / Scope**: Formatting. +* **Proposed Changes**: `formatBytes` binary units with 2 decimals + (`0 B`, `1.00 KiB`, ...; negative `-1.00 KiB`), `formatTime` using + `Intl.DateTimeFormat(undefined,{dateStyle:"medium",timeStyle:"medium"})`, + `formatUptime` as `0s`, `59s`, `1m 5s`, `2h 3m`, `2d 3h`. No state labels. + +#### 44. `web/src/style.css` (Create) + +* **Type / Scope**: Styles. +* **Proposed Changes**: Layout, tabs, cards, tables, badges, forms, error and + warning blocks, config textarea. System fonts; no external assets. Table + scroll at `max-width: 900px`. Class names shared with components. + +#### 45. `web/src/vite-env.d.ts` (Create) + +* **Type / Scope**: Vite types. + +#### 46. `web/src/app.tsx` (Create) + +* **Type / Scope**: App shell. +* **Proposed Changes**: Own tabs (`status`, `config`), status fetch/poll every + 5000ms when visible and `document.visibilityState === "visible"`, abort the + previous request, and fetch `/api/config` for the banner. Fetch + `/api/vgs`/`/api/classes` for the config tab and refresh them after live + actions. Props: `StatusPage{status,loading,error,onRefresh}`, + `ConfigPage{config,vgs,classes,onSaved,onDirtyChange,onRefreshVGs,onReloadConfig}`. + The config banner uses `config_changed`/`restart_required`; no restart + action. + +#### 47. `web/src/components/StatusPage.tsx` (Create) + +* **Type / Scope**: Status view. +* **Proposed Changes**: Props `{status, loading, error, onRefresh}`. Render + agent summary, config-changed/restart-required banner (message only), a + live iSCSI traffic card, a read-only VG table, volume table, operation + table, and errors. The traffic card shows cumulative read/write/command + totals, sampled rates, per-target volume/initiator rows, client IP/port + connections, LUN status, and error/reset counters; render `—` when + `rates_available` is false. The `TrafficCard` child component owns the + tables, the Disable action, and error-detail formatting. + Pass truncation flags. No restart button and no VG/class mutation controls; + those live in `ConfigPage`. + +#### 47a. `web/src/components/TrafficCard.tsx` (Create) + +* **Type / Scope**: Live iSCSI traffic view. +* **Proposed Changes**: Render aggregate read/write/command rates and totals, + session count, per-target volume/initiator rows, initiator RX/TX and PDU + counts, LUN status/busy/reset information, established client IP/port + connections, target mapping, and error details. Add a per-target Disable + button with a warning that active sessions will be dropped and that the + database definition remains. Show `—` for unavailable rates and explicit + unavailable/error messages when metrics or connection collection fails. + +#### 48. `web/src/components/ConfigPage.tsx` (Create) + +* **Type / Scope**: Config view. +* **Proposed Changes**: Props `{config, vgs, classes, onSaved, onDirtyChange, onRefreshVGs, onReloadConfig}`. Load disk + YAML, track dirty state, show running YAML in a read-only panel, ETag, + disk error code, warnings, `config_changed`/`restart_required` banner, and + a TLS card with the server certificate subject, DNS server names, IP + addresses, expiry, and parse error from `config.tls`. + Show a note that `storage.vgs` is part of the first-start `EffectiveVGs()` + seed and `storage.classes` is loaded every start; live VG/class changes + belong in `VgsPanel`. + Save posts YAML, updates the ETag from the response, and calls `onSaved`. + On `409` reload; on `422` show parser error. Render `VgsPanel` and + `CertificatesPanel` below the editor; after a successful VG/class action call + `onRefreshVGs` and `onReloadConfig`. Disable class set/remove while the YAML + editor is dirty, because those actions write the same config file; VG + register/deregister are state-only and may remain enabled. A pending raw + class edit is handled by the live class panel or by saving the raw file; + `.last-good` is not refreshed. No restart + control, no polling. Disable save when clean/in-flight; for + unsafe/missing/read-error, follow the error code. Add `beforeunload` dirty + warning. + +#### 49. `web/src/components/VgsPanel.tsx` (Create) + +* **Type / Scope**: Live VG/class controls. +* **Proposed Changes**: Props `{vgs, classes, classActionsDisabled, onRegister, onDeregister, onSetClass, onRemoveClass}`. Show registered/configured VG rows with capacity + and error. Add VG name input; remove button disabled when state is not + `active`; a VG with volumes returns `409` and the error is shown. Class + mapping input with VG select; remove button. Show per-action errors and + success messages. This panel is the live path for VG/class changes; + `storage.vgs` in raw YAML remains part of the first-start + `EffectiveVGs()` seed, and raw `storage.classes` edits apply on the next + start. No restart. + +#### 50. `web/src/components/VolumesTable.tsx` (Create) + +* **Type / Scope**: Volume table. +* **Proposed Changes**: Props `{volumes, truncated, total}`. Render identity, + owner, VG/class, sizes, state, serving, drift reasons, export, target/ACL/ + namespace presence, active sessions (`unknown` when null), session state, + ACL/observed initiator, observation times/stale, update time. Show an + **Enable target** action when an iSCSI export is `unexported`; it calls the + target-enable endpoint and refreshes status. A null + export/observation/export_observation renders `unknown`, not `false`. + Problems-only filter predicate: + `state === "drift" || state === "failed" || drift_reasons.length > 0`. + Horizontal scroll. + +#### 51. `web/src/components/OperationsTable.tsx` (Create) + +* **Type / Scope**: Operations table. +* **Proposed Changes**: Props `{operations, truncated, total}`. Render exact + operation fields; highlight failed/needs_attention. Show truncation. + +#### 52. `web/src/components/StateBadge.tsx` (Create) + +* **Type / Scope**: State badge. +* **Proposed Changes**: Props `{state, kind}`. Mapping for volume states + (`creating`, `available`, `exporting`, `exported`, `unexporting`, + `resizing`, `deleting`, `failed`, `drift`), export states (`exporting`, + `exported`, `unexporting`, + `unexported`, `failed`, `drift`), session states, operation states, and VG states + (`active`, `unavailable`, `unknown`, `not_registered`); unknown neutral + class. Exact labels/classes shared with `style.css`. + +#### 53. `web/README.md` (Create) + +* **Type / Scope**: Developer docs. +* **Proposed Changes**: `npm ci`, `npm run dev`, `npm run build`, typecheck, + output path, dev proxy, `make build` vs `make build-go`. + +#### 53a. `web/src/components/CertificatesPanel.tsx` (Create) + +* **Type / Scope**: Client certificate browser. +* **Proposed Changes**: Fetch `/api/certificates`; show directory, file name, + kind, role, size, modification time, and a download link. Provide a separate + “Download client CA” link from the same response's `ca` field. Warn when + private keys are present. The server restricts files to the configured + client certificate directory and rejects symlinks, sensitive CA/server + names, and path traversal. + +**Phase 6: Process wiring and build** + +#### 54. `cmd/kouja-agent/main.go` (Modify) + +* **Type / Scope**: Entry point. +* **Proposed Changes**: Load with `LoadWithData`; create state/lvm, a shared + `vgClassGate := &sync.Mutex{}`, the class map from `cfg.Storage.Classes`, + then the VG manager with that class map; pass the gate to `AdminService`, + `VolumeService` does not need it, and the admin `Options`; create the mTLS + server; create the admin server if enabled + (non-fatal); log a warning that the loopback admin UI has no authentication + and is root-equivalent; set gauge 0 on disabled; start backup/reconcile; set + readiness; start admin `Serve` and `MarkRunning` after readiness; wait on + signal/mTLS error; shut down admin with 5s, mTLS, wait goroutines. No + restart path. Add `startAdminServer(cfg, options) *adminui.Server` helper. + On admin Serve error call `adminServer.Shutdown(ctx)`, never a process stop + callback. Initialize `kouja_admin_ui_up` to 0. + +#### 55. `cmd/kouja-agent/main_test.go` (Create) + +* **Type / Scope**: Main tests. +* **Proposed Changes**: Test `startAdminServer` disabled, bind-failure, and + free-port cases. No exit-code/restart tests. + +#### 56. `Makefile` (Modify) + +* **Type / Scope**: Build. +* **Proposed Changes**: `NPM ?= npm`, `UI_DIR ?= web`. The Vite + `build.outDir` (`../internal/adminui/assets` from `web/`) is the single + output definition; the Makefile does not duplicate it. `clean-assets` + target removes the fixed asset directory except `.gitkeep`/`.gitignore`. + `ui: clean-assets` runs `cd $(UI_DIR) && $(NPM) ci`, + `cd $(UI_DIR) && $(NPM) run build`, then verifies + `internal/adminui/assets/index.html` exists. `build: ui` then + `go build ./...`. `build-go: clean-assets` then `go build ./...`; this + guarantees the fallback page after a prior `make build`. Add all targets to + `.PHONY`. + +#### 57. `.gitignore` (Modify) + +* **Type/Scope**: Ignore `web/node_modules/`; generated assets use nested + ignore; no TypeScript build info. + +#### 58. `.github/workflows/ci.yml` (Modify) + +* **Type/Scope**: Node 22.12.0 with npm cache; `make build`; Go tests/lint; + required `make build-go` fallback check. + +#### 59. `deploy/install/install.sh` (Modify) + +* **Type/Scope**: Add disabled `admin_ui` section; state the single-admin/ + root-equivalent/SSRF precondition; no port-forwarding. + +#### 60. `README.md` (Modify) + +* **Type/Scope**: Add admin UI capability, Node build vs `make build-go`, + config example, live VG actions and local-only class actions, + config-changed/restart-required policy (manual restart only for binding + socket changes), `.bak`/`.last-good` recovery, security precondition, + `kouja_admin_ui_up`, `web/` and `internal/adminui`/`internal/fsutil` layout + entries, and the design link. + +#### 61. `docs/designs/kouja.md` (Modify) + +* **Type/Scope**: Base design relationship. +* **Proposed Changes**: Update the Alternative N supersession note and the + compatibility bullet to say the loopback listener is limited to the local + configuration/status UI and live VG/class actions, while the mTLS admin + service remains the authenticated API for volume lifecycle and other + administrative operations. Do not change the base VG seed model. + +#### Phase checkpoints + +* After section 1: `go test -tags=unit ./internal/config ./internal/fsutil`. +* After section 2: `go test -tags=unit ./internal/agent`. +* After section 3: `go test -tags=unit ./internal/state`. +* After section 4: `go test -tags=unit ./internal/adminui`. +* After section 5: `cd web && npm run typecheck && npm run build`. +* After section 6: `go test -tags=unit ./cmd/kouja-agent`, `make build`, + `make build-go`, `make test`, `make coverage-check`, `make lint`. + +#### Verification Plan + +* **Frontend**: `make ui` with Node 22.12; typecheck/build pass; assets + contain `index.html` + hashed JS/CSS; no inline script. +* **Builds**: `make build` and `make build-go`; `make build-go` after a + prior `make build` cleans the generated assets and serves the fallback page; + `go build ./...` on a clean tree. +* **Unit**: config admin_ui/LoadWithData; live VG/class services; state joins, + limits 256/64, arrays; config read/save/ETag/lock/backup/last-good; + HTTP statuses 400/403/404/405/409/413/415/422/428/500/503; Host/Origin/ + Sec-Fetch-Site; VG/class handlers; audit; status serving/stale/unknown; + main admin disabled/failure. +* **No-restart verification**: assert no `/api/restart` route, no systemctl + invocation, no exit-75 path; config save returns messages only. +* **Live VG/class**: add/remove VG, add/remove class, persistence across + restart, VG-in-use rejection, config write failure rollback. +* **BDD/coverage/lint**: `make test-bdd`, `make coverage-check`, `make lint`. +* **Manual host**: enable UI; status per-volume; add/remove VG live; class + live; save non-socket config (config_changed, no restart); save socket + change (restart_required, manual systemctl); admin port occupied (mTLS + continues); disabled (no port, no `.last-good`); failed manual restart + recovery; no external network exposure. +* **Residual gaps**: no browser automation; full `run()` shutdown ordering is + manual. Documented. + +-------------------------------------------------------------------------------- + + + +*Drafted with EGM* diff --git a/docs/designs/kouja.md b/docs/designs/kouja.md index 0dad97b..ff72ec9 100644 --- a/docs/designs/kouja.md +++ b/docs/designs/kouja.md @@ -956,6 +956,14 @@ The backup job requires `backup_target` to be a writable off-host mount path. It * **Cons**: a separate listener adds certificates, ports, and firewall rules. A local socket makes remote automation depend on SSH or a jump host. * **Reason for Rejection**: the admin service shares the listener and is authorized by an admin role in the client certificate. The volume and admin APIs are separate services and URL prefixes, but one mTLS endpoint. +> **Superseded for the local admin UI (2026-11-06):** A separate, +> disabled-by-default loopback listener now serves the local admin UI described +> in [admin-ui.md](admin-ui.md). The mTLS admin service remains the +> authenticated API for volume lifecycle and other administrative operations. +> The loopback listener is limited to the local configuration/status UI and +> live VG/class actions. This note does not change the rejection of a +> network-exposed second admin API. + ### Alternative O: CLI wrappers or `targetd` * **Pros**: `targetcli` and `nvmetcli` are existing tools, and `targetd` already manages LVM and LIO. @@ -1004,7 +1012,7 @@ The backup job requires `backup_target` to be a writable off-host mount path. It * The CSI driver is part of the project and ships in stage 2, so the Kubernetes path has a supported client. It is a client of the same API, not a second control plane inside kouja. * SQLite is the only state store, with WAL, full synchronous writes, tested backups, and fail-closed behavior. * Mutations are synchronous and serialized per volume, with a per-VG lock around capacity and allocation. -* The admin API shares the listener and uses an admin role in the certificate. +* The admin API shares the mTLS listener and uses an admin role in the certificate. The separate loopback listener in [admin-ui.md](admin-ui.md) is limited to the local configuration/status UI and live VG/class actions, and is disabled by default. * The agent is written in Go and writes configfs directly. ## Risks & Mitigations diff --git a/internal/adminui/adminui_test.go b/internal/adminui/adminui_test.go new file mode 100644 index 0000000..a39fd0c --- /dev/null +++ b/internal/adminui/adminui_test.go @@ -0,0 +1,1250 @@ +package adminui + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "errors" + "io" + "log/slog" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/voidstarit/kouja/internal/agent" + "github.com/voidstarit/kouja/internal/config" + "github.com/voidstarit/kouja/internal/lvm" + "github.com/voidstarit/kouja/internal/operations" + "github.com/voidstarit/kouja/internal/state" + "github.com/voidstarit/kouja/internal/target" + "gopkg.in/yaml.v3" +) + +type testTargetMetricsProvider struct { + metrics target.ISCSIMetrics + err error +} + +func (provider *testTargetMetricsProvider) Metrics(context.Context) (target.ISCSIMetrics, error) { + return provider.metrics, provider.err +} + +type testTargetDisabler struct { + calls int + enableCalls int + owner string + volumeID string + err error + enableErr error +} + +func (disabler *testTargetDisabler) DisableTarget(_ context.Context, owner, volumeID string) error { + disabler.calls++ + disabler.owner = owner + disabler.volumeID = volumeID + return disabler.err +} + +func (disabler *testTargetDisabler) EnableTarget(_ context.Context, owner, volumeID string) error { + disabler.enableCalls++ + disabler.owner = owner + disabler.volumeID = volumeID + return disabler.enableErr +} + +type testVGService struct { + vgs []state.VG + capacity lvm.Capacity +} + +func (service *testVGService) Register(_ context.Context, name, _ string, _ bool) (lvm.Capacity, error) { + service.capacity.VGName = name + service.vgs = append(service.vgs, state.VG{Name: name, UUID: service.capacity.VGUUID, State: "active", RegisteredAt: time.Now(), UpdatedAt: time.Now()}) + return service.capacity, nil +} +func (service *testVGService) Deregister(_ context.Context, name string) error { + for index, vg := range service.vgs { + if vg.Name == name { + service.vgs = append(service.vgs[:index], service.vgs[index+1:]...) + return nil + } + } + return agent.ErrVGNotFound +} +func (service *testVGService) List(context.Context) ([]state.VG, error) { + return append([]state.VG(nil), service.vgs...), nil +} +func (service *testVGService) Inspect(_ context.Context, name string) (lvm.Capacity, error) { + capacity := service.capacity + capacity.VGName = name + return capacity, nil +} + +type testCapacity struct { + capacity lvm.Capacity +} + +func (provider *testCapacity) Capacity(context.Context, string) (lvm.Capacity, error) { + return provider.capacity, nil +} + +func newTestServer(t *testing.T) (*Server, *state.Store, string, *testVGService) { + t.Helper() + dir := t.TempDir() + cfg := config.Default() + cfg.AdminUI.Enabled = true + cfg.AdminUI.Address = "127.0.0.1:9444" + cfg.Listen.Address = "127.0.0.1:9443" + cfg.Targets.ISCSI.Portals = []config.Portal{{Address: "127.0.0.1", Port: 3260}} + cfg.Storage.VGs = []string{"vg1"} + cfg.Storage.Classes = map[string]string{} + raw, err := yaml.Marshal(&cfg) + if err != nil { + t.Fatal(err) + } + configPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configPath, raw, 0o600); err != nil { + t.Fatal(err) + } + parsed, err := config.Parse(raw) + if err != nil { + t.Fatal(err) + } + store, err := state.Open(context.Background(), filepath.Join(dir, "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := store.Transact(context.Background(), func(tx state.Tx) error { + now := time.Now().UTC() + if err := tx.PutVG(state.VG{Name: "vg1", UUID: "vg-uuid", State: "active", RegisteredAt: now, UpdatedAt: now}); err != nil { + return err + } + return tx.PutVolume(state.Volume{ + Owner: "owner", VolumeID: "vol1", ResourceUUID: "resource-1", + LVName: "lv-vol1", VGName: "vg1", RequestedSizeBytes: 1024, + ActualSizeBytes: 1024, State: "exported", CreatedAt: now, UpdatedAt: now, + }) + }); err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + classes := agent.NewClassMap(parsed.Storage.Classes, configPath) + vgs := &testVGService{ + vgs: []state.VG{{Name: "vg1", UUID: "vg-uuid", State: "active", RegisteredAt: time.Now(), UpdatedAt: time.Now()}}, + capacity: lvm.Capacity{ + VGName: "vg1", VGUUID: "vg-uuid", VGState: "active", + TotalBytes: 10 << 30, FreeBytes: 8 << 30, AllocatableBytes: 7 << 30, + ExtentSizeBytes: 4 << 20, MaxVolumeBytes: 1 << 40, + ObservedAt: time.Now().UTC(), + }, + } + server, err := New(Options{ + ConfigPath: configPath, Config: parsed, ConfigData: raw, + Store: store, Capacity: &testCapacity{capacity: vgs.capacity}, VGs: vgs, Classes: classes, + Ready: func() bool { return true }, HostUUID: "host-uuid", + AgentVersion: "test", APIVersion: "v1", StartedAt: time.Now(), + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + Now: time.Now, Listener: listener, + }) + if err != nil { + t.Fatal(err) + } + if err := server.MarkRunning(); err != nil { + t.Fatal(err) + } + return server, store, configPath, vgs +} + +func doRequest(t *testing.T, server *Server, method, path string, body []byte, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(method, path, bytes.NewReader(body)) + request.Host = server.Address().String() + for key, value := range headers { + request.Header.Set(key, value) + } + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, request) + return recorder +} + +func TestAdminUIStatusConfigAndMutations(t *testing.T) { + server, _, configPath, _ := newTestServer(t) + + response := doRequest(t, server, http.MethodGet, "/api/status", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("status code = %d body=%s", response.Code, response.Body.String()) + } + var status StatusResponse + if err := jsonUnmarshal(response.Body.Bytes(), &status); err != nil { + t.Fatal(err) + } + if !status.Agent.Ready || len(status.VGs) != 1 || len(status.Volumes) != 1 { + t.Fatalf("unexpected status: %#v", status) + } + if !status.ISCSI.Enabled || status.ISCSI.IQNPrefix == "" || len(status.ISCSI.Portals) != 1 { + t.Fatalf("unexpected iSCSI info: %#v", status.ISCSI) + } + if status.Volumes[0].Serving { + t.Fatal("volume without observations should not be serving") + } + + configResponse := doRequest(t, server, http.MethodGet, "/api/config", nil, nil) + if configResponse.Code != http.StatusOK { + t.Fatalf("config code = %d body=%s", configResponse.Code, configResponse.Body.String()) + } + var current ConfigResponse + if err := jsonUnmarshal(configResponse.Body.Bytes(), ¤t); err != nil { + t.Fatal(err) + } + if !current.DiskValid || current.DiskETag == "" || current.RunningYAML == "" { + t.Fatalf("unexpected config: %#v", current) + } + + parsed, err := config.Parse([]byte(current.DiskYAML)) + if err != nil { + t.Fatal(err) + } + parsed.Storage.HeadroomPercent++ + candidate, err := yaml.Marshal(parsed) + if err != nil { + t.Fatal(err) + } + save := doRequest(t, server, http.MethodPut, "/api/config", candidate, map[string]string{ + "Content-Type": "application/yaml", "If-Match": current.DiskETag, + }) + if save.Code != http.StatusOK { + t.Fatalf("save code = %d body=%s", save.Code, save.Body.String()) + } + var saved SaveConfigResponse + if err := jsonUnmarshal(save.Body.Bytes(), &saved); err != nil { + t.Fatal(err) + } + if !saved.Saved || !saved.ConfigChanged || saved.RestartRequired { + t.Fatalf("unexpected save response: %#v", saved) + } + + register := doRequest(t, server, http.MethodPost, "/api/vgs", []byte(`{"name":"vg2"}`), map[string]string{"Content-Type": "application/json"}) + if register.Code != http.StatusCreated { + t.Fatalf("register code = %d body=%s", register.Code, register.Body.String()) + } + classes := doRequest(t, server, http.MethodPut, "/api/classes/ssd", []byte(`{"vg":"vg1"}`), map[string]string{"Content-Type": "application/json"}) + if classes.Code != http.StatusOK { + t.Fatalf("class code = %d body=%s", classes.Code, classes.Body.String()) + } + updated, err := os.ReadFile(configPath) //nolint:gosec // test-controlled path + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(updated, []byte("ssd: vg1")) { + t.Fatalf("class mapping not persisted: %s", updated) + } +} + +func jsonUnmarshal(data []byte, target any) error { + return json.Unmarshal(data, target) +} + +func TestConfigHelpers(t *testing.T) { + dir := t.TempDir() + missing, _ := readConfig(filepath.Join(dir, "missing.yaml")) + if missing.DiskErrorCode != "missing" || missing.ETag != "*" { + t.Fatalf("missing = %#v", missing) + } + invalidPath := filepath.Join(dir, "invalid.yaml") + if err := os.WriteFile(invalidPath, []byte("not: [valid"), 0o600); err != nil { + t.Fatal(err) + } + invalid, _ := readConfig(invalidPath) + if invalid.DiskErrorCode != "parse_error" || invalid.ETag == "" || len(invalid.Raw) == 0 { + t.Fatalf("invalid = %#v", invalid) + } + link := filepath.Join(dir, "link.yaml") + if err := os.Symlink(invalidPath, link); err != nil { + t.Fatal(err) + } + if unsafe, _ := readConfig(link); unsafe.DiskErrorCode != "unsafe_path" { + t.Fatalf("symlink = %#v", unsafe) + } + if unsafe, _ := readConfig(dir); unsafe.DiskErrorCode != "unsafe_path" { + t.Fatalf("directory = %#v", unsafe) + } + + running := config.Default() + running.Storage.VGs = []string{"vg1"} + running.Targets.ISCSI.Portals = []config.Portal{{Address: "127.0.0.1", Port: 3260}, {Address: "127.0.0.2", Port: 3260}} + candidate := running + candidate.Targets.ISCSI.Portals = []config.Portal{{Address: "127.0.0.2", Port: 3260}, {Address: "127.0.0.1", Port: 3260}} + if changed, reason := bindingSocketChanged(&running, &candidate); changed || reason != "" { + t.Fatalf("reorder should be set-equal: %v %q", changed, reason) + } + candidate.Targets.ISCSI.Portals = []config.Portal{{Address: "127.0.0.1", Port: 3261}} + if changed, reason := bindingSocketChanged(&running, &candidate); !changed || reason != "iscsi_portals_changed" { + t.Fatalf("portal change = %v %q", changed, reason) + } + candidate = running + candidate.Listen.Address = "127.0.0.1:9555" + if changed, reason := bindingSocketChanged(&running, &candidate); !changed || reason != "listen_address_changed" { + t.Fatalf("listen change = %v %q", changed, reason) + } + candidate = running + candidate.AdminUI.Address = "127.0.0.1:9556" + if changed, _ := bindingSocketChanged(&running, &candidate); changed { + t.Fatal("disabled admin address change should not require restart") + } + candidate = running + candidate.AdminUI.Enabled = true + if changed, reason := bindingSocketChanged(&running, &candidate); !changed || reason != "admin_enabled_changed" { + t.Fatalf("admin enabled = %v %q", changed, reason) + } + running.AdminUI.Enabled = true + candidate = running + candidate.AdminUI.Address = "127.0.0.1:9557" + if changed, reason := bindingSocketChanged(&running, &candidate); !changed || reason != "admin_address_changed" { + t.Fatalf("admin address = %v %q", changed, reason) + } + running.AdminUI.Enabled = false + candidate = running + candidate.Targets.NVMeTCP.Enabled = true + if changed, reason := bindingSocketChanged(&running, &candidate); !changed || reason != "nvme_tcp_enabled_changed" { + t.Fatalf("nvme enabled = %v %q", changed, reason) + } + if configEquivalent(nil, &running, nil) || configEquivalent(&running, nil, nil) { + t.Fatal("nil config compared equal") + } + + classes := agent.NewClassMap(map[string]string{"ssd": "vg1"}, "") + if !configEquivalent(&running, &running, classes) { + t.Fatal("identical configs differ") + } + other := running + other.Storage.HeadroomPercent++ + if configEquivalent(&running, &other, classes) { + t.Fatal("changed configs compare equal") + } + other = running + other.Storage.VGs = []string{"vg1", "vg1"} + if !configEquivalent(&running, &other, classes) { + t.Fatal("duplicate VG changed equivalence") + } + + if err := checkPreconditions(configFile{ETag: `"x"`}, `"x"`, ""); err != nil { + t.Fatal(err) + } + if err := checkPreconditions(configFile{ETag: `"x"`}, `"y"`, ""); !errors.Is(err, errConflict) { + t.Fatalf("mismatch = %v", err) + } + if err := checkPreconditions(configFile{DiskErrorCode: "missing", ETag: "*"}, "", "*"); err != nil { + t.Fatal(err) + } + if err := checkPreconditions(configFile{DiskErrorCode: "missing", ETag: "*"}, "", ""); !errors.Is(err, errPreconditionRequired) { + t.Fatalf("missing precondition = %v", err) + } +} + +func TestAdminUIStatusObservationsAndCapacityErrors(t *testing.T) { + server, store, _, _ := newTestServer(t) + now := time.Now().UTC() + if err := store.Transact(context.Background(), func(tx state.Tx) error { + if err := tx.PutExport(state.Export{ + Owner: "owner", VolumeID: "vol1", Protocol: "iscsi", Initiator: "iqn.i", + TargetName: "iqn.t", Portal: "127.0.0.1:3260", State: "exported", + CreatedAt: now, UpdatedAt: now, + }); err != nil { + return err + } + if err := tx.PutObservation(state.Observation{ + Owner: "owner", VolumeID: "vol1", LVPresent: true, ObservedSizeBytes: 1024, + DevicePath: "/dev/vg1/lv-vol1", ObservedAt: now, Source: "lvm", + }); err != nil { + return err + } + if err := tx.PutExportObservation(state.ExportObservation{ + Owner: "owner", VolumeID: "vol1", TargetPresent: true, NamespacePresent: true, + ACLPresent: true, ActiveSessions: 2, SessionState: "active", + ObservedInitiator: "iqn.i", ObservedAt: now, Source: "lio", + }); err != nil { + return err + } + return tx.PutOperation(state.Operation{ + ID: "op1", Owner: "owner", VolumeID: "vol1", ResourceUUID: "resource-1", + Action: "create", RequestHash: "hash", IntentJSON: "{}", State: "failed", + Attempt: 3, MaxAttempts: 3, DeadlineAt: now, CreatedAt: now, UpdatedAt: now, + }) + }); err != nil { + t.Fatal(err) + } + response := doRequest(t, server, http.MethodGet, "/api/status", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("status = %d %s", response.Code, response.Body.String()) + } + var status StatusResponse + if err := jsonUnmarshal(response.Body.Bytes(), &status); err != nil { + t.Fatal(err) + } + if !status.Volumes[0].Serving || status.Volumes[0].ExportObservation == nil || + status.Volumes[0].ExportObservation.ActiveSessions == nil || + *status.Volumes[0].ExportObservation.ActiveSessions != 2 { + t.Fatalf("volume view = %#v", status.Volumes[0]) + } + if len(status.Operations) != 1 || status.Counts.Operations != 1 { + t.Fatalf("operations = %#v count=%#v", status.Operations, status.Counts) + } + + server.options.Capacity = failingCapacity{} + response = doRequest(t, server, http.MethodGet, "/api/status", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("status after capacity failure = %d", response.Code) + } + if err := jsonUnmarshal(response.Body.Bytes(), &status); err != nil { + t.Fatal(err) + } + if len(status.Errors) == 0 || status.VGs[0].State != "unavailable" { + t.Fatalf("capacity failure view = %#v", status) + } + + old := now.Add(-10 * time.Hour) + if err := store.Transact(context.Background(), func(tx state.Tx) error { + return tx.PutObservation(state.Observation{ + Owner: "owner", VolumeID: "vol1", LVPresent: true, ObservedSizeBytes: 1024, + DevicePath: "/dev/vg1/lv-vol1", ObservedAt: old, Source: "lvm", + }) + }); err != nil { + t.Fatal(err) + } + response = doRequest(t, server, http.MethodGet, "/api/status", nil, nil) + if err := jsonUnmarshal(response.Body.Bytes(), &status); err != nil { + t.Fatal(err) + } + if !status.Volumes[0].Observation.Stale || status.Volumes[0].Serving { + t.Fatalf("stale observation = %#v", status.Volumes[0]) + } +} + +type failingCapacity struct{} + +func (failingCapacity) Capacity(context.Context, string) (lvm.Capacity, error) { + return lvm.Capacity{}, errors.New("capacity failed") +} + +func TestAdminUIStatusMetricsAndRates(t *testing.T) { + server, store, _, _ := newTestServer(t) + now := time.Now().UTC() + if err := store.Transact(context.Background(), func(tx state.Tx) error { + return tx.PutExport(state.Export{ + Owner: "owner", VolumeID: "vol1", Protocol: "iscsi", Initiator: "iqn.i", + TargetName: "iqn.t", Portal: "127.0.0.1:3260", State: "exported", + CreatedAt: now, UpdatedAt: now, + }) + }); err != nil { + t.Fatal(err) + } + provider := &testTargetMetricsProvider{metrics: target.ISCSIMetrics{ + Targets: []target.ISCSITargetMetrics{{ + Target: "iqn.t", Sessions: 1, + ClientAddresses: []string{"192.0.2.10"}, + Login: target.ISCSILoginMetrics{TargetLoginFails: 1}, + Initiators: []target.ISCSIInitiatorMetrics{{ + Initiator: "iqn.i", RXBytes: 2 << 20, TXBytes: 1 << 20, CommandPDUs: 10, + ClientAddresses: []string{"192.0.2.10"}, + }}, + LUNs: []target.ISCSILUNMetrics{{ + LUN: 0, ResourceUUID: "resource-1", Commands: 10, Status: "available", + }}, + }}, + Connections: []target.ISCSIClientConnection{{ + ClientAddress: "192.0.2.10", ClientPort: 40000, + LocalAddress: "192.0.2.1", LocalPort: 3260, + Target: "iqn.t", Initiator: "iqn.i", + }}, + }} + server.options.TargetMetrics = provider + server.options.Now = func() time.Time { return now } + + response := doRequest(t, server, http.MethodGet, "/api/status", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("status = %d %s", response.Code, response.Body.String()) + } + var status StatusResponse + if err := jsonUnmarshal(response.Body.Bytes(), &status); err != nil { + t.Fatal(err) + } + if !status.Metrics.Available || status.Metrics.RatesAvailable || len(status.Metrics.Targets) != 1 { + t.Fatalf("metrics = %#v", status.Metrics) + } + first := status.Metrics.Targets[0] + if first.VolumeOwner != "owner" || first.VolumeID != "vol1" || first.ReadBytes != 1<<20 || + first.WriteBytes != 2<<20 || first.Commands != 10 || first.RatesAvailable || + first.ErrorCount != 1 || first.Errors.LoginFailures != 1 || + len(first.ClientAddresses) != 1 || first.ClientAddresses[0] != "192.0.2.10" { + t.Fatalf("target metrics = %#v", first) + } + if len(status.Metrics.Connections) != 1 || + status.Metrics.Connections[0].ClientAddress != "192.0.2.10" || + status.Metrics.Connections[0].ClientPort != 40000 || + status.Metrics.Connections[0].Target != "iqn.t" || + status.Metrics.Connections[0].Initiator != "iqn.i" { + t.Fatalf("connections = %#v", status.Metrics.Connections) + } + + now = now.Add(2 * time.Second) + provider.metrics.Targets[0].Initiators[0].RXBytes = 5 << 20 + provider.metrics.Targets[0].Initiators[0].TXBytes = 3 << 20 + provider.metrics.Targets[0].LUNs[0].Commands = 20 + response = doRequest(t, server, http.MethodGet, "/api/status", nil, nil) + if err := jsonUnmarshal(response.Body.Bytes(), &status); err != nil { + t.Fatal(err) + } + if !status.Metrics.RatesAvailable || !status.Metrics.Targets[0].RatesAvailable { + t.Fatalf("rates unavailable: %#v", status.Metrics) + } + if status.Metrics.Targets[0].ReadBytesPerSecond != float64(2<<20)/2 || + status.Metrics.Targets[0].WriteBytesPerSecond != float64(3<<20)/2 || + status.Metrics.Targets[0].CommandsPerSecond != 5 || + status.Metrics.ReadBytesPerSecond != float64(2<<20)/2 || + status.Metrics.WriteBytesPerSecond != float64(3<<20)/2 { + t.Fatalf("rates = %#v", status.Metrics) + } + + provider.err = errors.New("configfs unavailable") + response = doRequest(t, server, http.MethodGet, "/api/status", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("metrics error changed status = %d", response.Code) + } + if err := jsonUnmarshal(response.Body.Bytes(), &status); err != nil { + t.Fatal(err) + } + if status.Metrics.Available || status.Metrics.Error == "" { + t.Fatalf("metrics error = %#v", status.Metrics) + } +} + +func TestAdminUIDisableISCSITarget(t *testing.T) { + server, store, _, _ := newTestServer(t) + now := time.Now().UTC() + if err := store.Transact(context.Background(), func(tx state.Tx) error { + return tx.PutExport(state.Export{ + Owner: "owner", VolumeID: "vol1", Protocol: "iscsi", Initiator: "iqn.i", + TargetName: "iqn.t", Portal: "127.0.0.1:3260", State: "exported", + CreatedAt: now, UpdatedAt: now, + }) + }); err != nil { + t.Fatal(err) + } + server.options.DisableTarget = nil + server.options.EnableTarget = nil + response := doRequest(t, server, http.MethodPost, "/api/targets/disable", + []byte(`{"owner":"owner","volume_id":"vol1"}`), + map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("unconfigured disable = %d", response.Code) + } + response = doRequest(t, server, http.MethodPost, "/api/targets/enable", + []byte(`{"owner":"owner","volume_id":"vol1"}`), + map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("unconfigured enable = %d", response.Code) + } + disabler := &testTargetDisabler{} + server.options.DisableTarget = disabler + server.options.EnableTarget = disabler + response = doRequest(t, server, http.MethodPost, "/api/targets/disable", + []byte(`{"owner":"owner","volume_id":"vol1"}`), + map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusNoContent { + t.Fatalf("disable = %d %s", response.Code, response.Body.String()) + } + if disabler.calls != 1 || disabler.owner != "owner" || disabler.volumeID != "vol1" { + t.Fatalf("disable call = %#v", disabler) + } + response = doRequest(t, server, http.MethodPost, "/api/targets/enable", + []byte(`{"owner":"owner","volume_id":"vol1"}`), + map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusNoContent { + t.Fatalf("enable = %d %s", response.Code, response.Body.String()) + } + if disabler.enableCalls != 1 || disabler.owner != "owner" || disabler.volumeID != "vol1" { + t.Fatalf("enable call = %#v", disabler) + } + + if err := store.Transact(context.Background(), func(tx state.Tx) error { + return tx.PutExport(state.Export{ + Owner: "owner", VolumeID: "vol1", Protocol: "nvme-tcp", Initiator: "iqn.i", + TargetName: "nqn.t", Portal: "127.0.0.1:4420", State: "exported", + CreatedAt: now, UpdatedAt: now, + }) + }); err != nil { + t.Fatal(err) + } + response = doRequest(t, server, http.MethodPost, "/api/targets/disable", + []byte(`{"owner":"owner","volume_id":"vol1"}`), + map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusUnprocessableEntity { + t.Fatalf("non-iSCSI target = %d", response.Code) + } + response = doRequest(t, server, http.MethodPost, "/api/targets/disable", + []byte(`{"owner":"owner","volume_id":"missing"}`), + map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusNotFound { + t.Fatalf("missing target = %d", response.Code) + } + response = doRequest(t, server, http.MethodPost, "/api/targets/disable", + []byte(`{"owner":"owner"}`), + map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusBadRequest { + t.Fatalf("invalid request = %d", response.Code) + } +} + +func TestTargetActionErrorMappings(t *testing.T) { + tests := []struct { + err error + status int + code string + }{ + {state.ErrNotFound, http.StatusNotFound, "not_found"}, + {operations.ErrVolumeInDrift, http.StatusConflict, "volume_in_drift"}, + {operations.ErrSessionActive, http.StatusConflict, "session_active"}, + {operations.ErrSessionUnknown, http.StatusConflict, "session_unknown"}, + {operations.ErrOperationInProgress, http.StatusConflict, "operation_in_progress"}, + {operations.ErrVolumeAlreadyExport, http.StatusConflict, "already_exported"}, + {operations.ErrInvalidRequest, http.StatusUnprocessableEntity, "invalid_target"}, + {target.ErrConflict, http.StatusConflict, "conflict"}, + {context.Canceled, http.StatusGatewayTimeout, "timeout"}, + {errors.New("other"), http.StatusInternalServerError, "internal_error"}, + } + for _, test := range tests { + recorder := httptest.NewRecorder() + writeTargetActionError(recorder, test.err) + var body APIError + if err := jsonUnmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if recorder.Code != test.status || body.Code != test.code { + t.Fatalf("%v: status=%d code=%q, want %d %q", test.err, recorder.Code, body.Code, test.status, test.code) + } + } +} + +func TestMetricsViewFallbackAndCounts(t *testing.T) { + view := targetMetricsView(target.ISCSITargetMetrics{ + Target: "iqn.t", + LUNs: []target.ISCSILUNMetrics{{ + LUN: 0, Commands: 4, ReadBytes: 2, WriteBytes: 3, + Status: "available", Error: "stat unavailable", + }}, + }, state.VolumeStatus{}) + if view.VolumeID != "" || view.ReadBytes != 2 || view.WriteBytes != 3 || + view.Commands != 4 || !strings.Contains(view.Error, "LUN 0") { + t.Fatalf("fallback view = %#v", view) + } + errors := MetricsErrorsView{AuthenticationFailures: 2, Resets: 1} + if metricsErrorCount(errors) != 3 { + t.Fatalf("fallback error count = %d", metricsErrorCount(errors)) + } + if value, ok := rate(10, 9, 1); ok || value != 0 { + t.Fatalf("counter reset rate = %v %v", value, ok) + } + if value, ok := rate(10, 20, 2); !ok || value != 5 { + t.Fatalf("rate = %v %v", value, ok) + } + + server, _, _, _ := newTestServer(t) + noProvider := server.buildMetrics(context.Background(), nil) + if noProvider.Available || noProvider.Error == "" { + t.Fatalf("no provider metrics = %#v", noProvider) + } +} + +func TestAdminUIHTTPGuardsAndAssets(t *testing.T) { + server, _, _, _ := newTestServer(t) + server.assets = newAssetFS(fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("ok")}, + "app.js": &fstest.MapFile{Data: []byte("console.log('ok')")}, + }) + response := doRequest(t, server, http.MethodGet, "/", nil, nil) + if response.Code != http.StatusOK || !bytes.Contains(response.Body.Bytes(), []byte("ok")) { + t.Fatalf("index = %d %s", response.Code, response.Body.String()) + } + response = doRequest(t, server, http.MethodGet, "/assets/app.js", nil, nil) + if response.Code != http.StatusOK || response.Header().Get("Cache-Control") == "" { + t.Fatalf("asset = %d %#v", response.Code, response.Header()) + } + response = doRequest(t, server, http.MethodGet, "/favicon.ico", nil, nil) + if response.Code != http.StatusNoContent { + t.Fatalf("favicon = %d", response.Code) + } + response = doRequest(t, server, http.MethodGet, "/assets/missing.js", nil, nil) + if response.Code != http.StatusNotFound { + t.Fatalf("missing asset = %d", response.Code) + } + request := httptest.NewRequest(http.MethodGet, "/api/status", nil) + request.Host = "example.com" + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusForbidden { + t.Fatalf("bad host = %d", recorder.Code) + } + response = doRequest(t, server, http.MethodPost, "/api/vgs", []byte(`{"name":"vg1"}`), map[string]string{ + "Content-Type": "application/json", "Sec-Fetch-Site": "cross-site", + }) + if response.Code != http.StatusForbidden { + t.Fatalf("cross-site = %d", response.Code) + } + response = doRequest(t, server, http.MethodPost, "/api/vgs", []byte(`{}`), map[string]string{"Content-Type": "text/plain"}) + if response.Code != http.StatusUnsupportedMediaType { + t.Fatalf("content type = %d", response.Code) + } + response = doRequest(t, server, http.MethodOptions, "/api/status", nil, nil) + if response.Code != http.StatusMethodNotAllowed { + t.Fatalf("options = %d", response.Code) + } + server.assets = newAssetFS(fstest.MapFS{}) + response = doRequest(t, server, http.MethodGet, "/", nil, nil) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("fallback = %d", response.Code) + } +} + +func TestAdminUIRestartAndInvalidSave(t *testing.T) { + server, _, _, _ := newTestServer(t) + configResponse := doRequest(t, server, http.MethodGet, "/api/config", nil, nil) + var current ConfigResponse + if err := jsonUnmarshal(configResponse.Body.Bytes(), ¤t); err != nil { + t.Fatal(err) + } + parsed, err := config.Parse([]byte(current.DiskYAML)) + if err != nil { + t.Fatal(err) + } + parsed.Listen.Address = "127.0.0.1:9555" + candidate, err := yaml.Marshal(parsed) + if err != nil { + t.Fatal(err) + } + save := doRequest(t, server, http.MethodPut, "/api/config", candidate, map[string]string{ + "Content-Type": "application/yaml", "If-Match": current.DiskETag, + }) + if save.Code != http.StatusOK { + t.Fatalf("restart save = %d %s", save.Code, save.Body.String()) + } + var saved SaveConfigResponse + if err := jsonUnmarshal(save.Body.Bytes(), &saved); err != nil { + t.Fatal(err) + } + if !saved.RestartRequired || saved.RestartReason != "listen_address_changed" { + t.Fatalf("restart response = %#v", saved) + } + stale := doRequest(t, server, http.MethodPut, "/api/config", candidate, map[string]string{ + "Content-Type": "application/yaml", "If-Match": current.DiskETag, + }) + if stale.Code != http.StatusConflict { + t.Fatalf("stale save = %d", stale.Code) + } + invalid := doRequest(t, server, http.MethodPut, "/api/config", []byte("not: [valid"), map[string]string{ + "Content-Type": "application/yaml", "If-Match": saved.DiskETag, + }) + if invalid.Code != http.StatusUnprocessableEntity { + t.Fatalf("invalid save = %d %s", invalid.Code, invalid.Body.String()) + } +} + +func TestAdminUIListsDeregisterAndRemove(t *testing.T) { + server, _, _, _ := newTestServer(t) + response := doRequest(t, server, http.MethodGet, "/api/vgs", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("vgs = %d", response.Code) + } + var list VGListResponse + if err := jsonUnmarshal(response.Body.Bytes(), &list); err != nil || len(list.VGs) != 1 { + t.Fatalf("vgs body = %s", response.Body.String()) + } + response = doRequest(t, server, http.MethodGet, "/api/classes", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("classes = %d", response.Code) + } + response = doRequest(t, server, http.MethodPost, "/api/vgs", []byte(`{"name":"vg2"}`), map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusCreated { + t.Fatalf("register = %d", response.Code) + } + response = doRequest(t, server, http.MethodDelete, "/api/vgs/vg2", nil, map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusNoContent { + t.Fatalf("deregister = %d %s", response.Code, response.Body.String()) + } + response = doRequest(t, server, http.MethodPut, "/api/classes/ssd", []byte(`{"vg":"vg1"}`), map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusOK { + t.Fatalf("set class = %d", response.Code) + } + response = doRequest(t, server, http.MethodDelete, "/api/classes/ssd", nil, map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusNoContent { + t.Fatalf("remove class = %d %s", response.Code, response.Body.String()) + } + response = doRequest(t, server, http.MethodPut, "/api/classes/ssd", []byte(`{"vg":"missing"}`), map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusUnprocessableEntity { + t.Fatalf("missing class VG = %d", response.Code) + } + response = doRequest(t, server, http.MethodDelete, "/api/vgs/vg1", nil, map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusNoContent { + t.Fatalf("deregister vg1 = %d %s", response.Code, response.Body.String()) + } +} + +func TestAdminUIMissingConfigAndStatusConfigError(t *testing.T) { + server, _, configPath, _ := newTestServer(t) + if err := os.Remove(configPath); err != nil { + t.Fatal(err) + } + response := doRequest(t, server, http.MethodGet, "/api/config", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("config = %d", response.Code) + } + var current ConfigResponse + if err := jsonUnmarshal(response.Body.Bytes(), ¤t); err != nil { + t.Fatal(err) + } + if current.DiskValid || current.DiskErrorCode != "missing" || current.DiskETag != "*" { + t.Fatalf("missing config = %#v", current) + } + response = doRequest(t, server, http.MethodGet, "/api/status", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("status = %d", response.Code) + } + var status StatusResponse + if err := jsonUnmarshal(response.Body.Bytes(), &status); err != nil { + t.Fatal(err) + } + if len(status.Errors) == 0 || status.Errors[0].Code != "config_missing" { + t.Fatalf("config error = %#v", status.Errors) + } + cfg := config.Default() + cfg.AdminUI.Enabled = true + cfg.Targets.ISCSI.Portals = []config.Portal{{Address: "127.0.0.1", Port: 3260}} + raw, err := yaml.Marshal(&cfg) + if err != nil { + t.Fatal(err) + } + response = doRequest(t, server, http.MethodPut, "/api/config", raw, map[string]string{ + "Content-Type": "application/yaml", "If-None-Match": "*", + }) + if response.Code != http.StatusOK { + t.Fatalf("create = %d %s", response.Code, response.Body.String()) + } +} + +func TestAdminUINewValidationAndMappedErrors(t *testing.T) { + if _, err := New(Options{}); err == nil { + t.Fatal("empty New succeeded") + } + cfg := config.Default() + cfg.AdminUI.Enabled = false + if _, err := New(Options{ConfigPath: "x", Config: &cfg, ConfigData: []byte("x"), Store: nil}); err == nil { + t.Fatal("disabled/incomplete New succeeded") + } + cases := []struct { + err error + status int + code string + }{ + {errBadRequest, http.StatusBadRequest, "bad_request"}, + {errInvalidConfig, http.StatusUnprocessableEntity, "invalid_config"}, + {errUnsafePath, http.StatusUnprocessableEntity, "invalid_config"}, + {errConflict, http.StatusConflict, "conflict"}, + {errPreconditionRequired, http.StatusPreconditionRequired, "precondition_required"}, + {errLocked, http.StatusConflict, "conflict"}, + {errStoreUnavailable, http.StatusServiceUnavailable, "unavailable"}, + {agent.ErrVGInUse, http.StatusConflict, "vg_in_use"}, + {agent.ErrVGConflict, http.StatusConflict, "vg_conflict"}, + {agent.ErrVGClassMapped, http.StatusConflict, "class_conflict"}, + {agent.ErrVGUnavailable, http.StatusUnprocessableEntity, "vg_unavailable"}, + {agent.ErrVGUUIDMismatch, http.StatusUnprocessableEntity, "vg_unavailable"}, + {agent.ErrVGNotFound, http.StatusNotFound, "not_found"}, + {config.ErrInvalid, http.StatusUnprocessableEntity, "invalid_config"}, + {config.ErrClassNotFound, http.StatusNotFound, "not_found"}, + {config.ErrClassConflict, http.StatusConflict, "class_conflict"}, + {config.ErrLocked, http.StatusConflict, "conflict"}, + {config.ErrUnsafePath, http.StatusUnprocessableEntity, "invalid_config"}, + {errors.New("other"), http.StatusInternalServerError, "internal_error"}, + } + for _, testCase := range cases { + recorder := httptest.NewRecorder() + writeMappedError(recorder, testCase.err) + if recorder.Code != testCase.status || !bytes.Contains(recorder.Body.Bytes(), []byte(testCase.code)) { + t.Fatalf("mapped %v = %d %s", testCase.err, recorder.Code, recorder.Body.String()) + } + } +} + +func TestAdminUIServeAndShutdown(t *testing.T) { + server, _, _, _ := newTestServer(t) + done := make(chan error, 1) + go func() { done <- server.Serve() }() + time.Sleep(10 * time.Millisecond) + response, err := http.Get("http://" + server.Address().String() + "/api/status") + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("served status = %d", response.StatusCode) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestAdminUIMutationValidationAndClassConflicts(t *testing.T) { + server, _, _, _ := newTestServer(t) + response := doRequest(t, server, http.MethodPut, "/api/classes/ssd", []byte(`{"vg":"vg1"}`), map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusOK { + t.Fatalf("set class = %d", response.Code) + } + response = doRequest(t, server, http.MethodPut, "/api/classes/other", []byte(`{"vg":"vg1"}`), map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusConflict { + t.Fatalf("class conflict = %d %s", response.Code, response.Body.String()) + } + response = doRequest(t, server, http.MethodDelete, "/api/classes/missing", nil, map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusNotFound { + t.Fatalf("missing class = %d", response.Code) + } + response = doRequest(t, server, http.MethodDelete, "/api/vgs/missing", nil, map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusNotFound { + t.Fatalf("missing VG = %d", response.Code) + } + response = doRequest(t, server, http.MethodPost, "/api/vgs", []byte(`not-json`), map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusBadRequest { + t.Fatalf("invalid JSON = %d", response.Code) + } + response = doRequest(t, server, http.MethodPost, "/api/vgs", []byte(`{"name":""}`), map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusBadRequest { + t.Fatalf("empty name = %d", response.Code) + } + response = doRequest(t, server, http.MethodPost, "/api/vgs", []byte(`{"name":"vg1"}`), map[string]string{ + "Content-Type": "application/json", "Origin": "null", + }) + if response.Code != http.StatusForbidden { + t.Fatalf("null origin = %d", response.Code) + } + response = doRequest(t, server, http.MethodPost, "/api/vgs", bytes.Repeat([]byte("x"), 1<<20+1), map[string]string{"Content-Type": "application/json"}) + if response.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("large body = %d", response.Code) + } +} + +func TestAdminUIInvalidConfigStatus(t *testing.T) { + server, _, configPath, _ := newTestServer(t) + if err := os.WriteFile(configPath, []byte("not: [valid"), 0o600); err != nil { + t.Fatal(err) + } + response := doRequest(t, server, http.MethodGet, "/api/config", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("config = %d", response.Code) + } + var current ConfigResponse + if err := jsonUnmarshal(response.Body.Bytes(), ¤t); err != nil { + t.Fatal(err) + } + if current.DiskValid || current.DiskErrorCode != "parse_error" { + t.Fatalf("invalid config = %#v", current) + } + response = doRequest(t, server, http.MethodGet, "/api/status", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("status = %d", response.Code) + } + var status StatusResponse + if err := jsonUnmarshal(response.Body.Bytes(), &status); err != nil { + t.Fatal(err) + } + if len(status.Errors) == 0 || status.Errors[0].Code != "config_invalid" { + t.Fatalf("config errors = %#v", status.Errors) + } +} + +func TestAdminUIEmptyListsAndFallbackAssets(t *testing.T) { + server, store, _, _ := newTestServer(t) + if err := store.Transact(context.Background(), func(tx state.Tx) error { + return tx.DeleteVolume("owner", "vol1") + }); err != nil { + t.Fatal(err) + } + response := doRequest(t, server, http.MethodGet, "/api/status", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("empty status = %d", response.Code) + } + var status StatusResponse + if err := jsonUnmarshal(response.Body.Bytes(), &status); err != nil { + t.Fatal(err) + } + if len(status.Volumes) != 0 || len(status.Operations) != 0 { + t.Fatalf("empty status = %#v", status) + } + server.assets = &assetFS{} + response = doRequest(t, server, http.MethodGet, "/", nil, nil) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("empty assets = %d", response.Code) + } + response = doRequest(t, server, http.MethodGet, "/assets/x.js", nil, nil) + if response.Code != http.StatusNotFound { + t.Fatalf("empty asset = %d", response.Code) + } +} + +func TestAdminUIRejectsNonLoopbackListener(t *testing.T) { + server, _, _, _ := newTestServer(t) + options := server.options + listener, err := net.Listen("tcp", "0.0.0.0:0") //nolint:gosec // listener rejection test + if err != nil { + t.Fatal(err) + } + options.Listener = listener + if _, err := New(options); err == nil { + t.Fatal("non-loopback listener was accepted") + } +} + +func TestDefaultAssetsWhenBuilt(t *testing.T) { + assets := defaultAssets() + if _, err := assets.read("index.html"); err != nil { + t.Skip("browser assets are not built") + } + server, _, _, _ := newTestServer(t) + server.assets = assets + response := doRequest(t, server, http.MethodGet, "/", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("built index = %d %s", response.Code, response.Body.String()) + } + index, _ := assets.read("index.html") + marker := strings.Index(string(index), "/assets/") + if marker < 0 { + t.Fatal("index has no asset reference") + } + rest := string(index)[marker+len("/assets/"):] + end := strings.IndexAny(rest, "\"' ") + if end < 0 { + end = len(rest) + } + asset := rest[:end] + response = doRequest(t, server, http.MethodGet, "/assets/"+asset, nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("built asset %q = %d", asset, response.Code) + } +} + +func TestWarningViewsAndPathNames(t *testing.T) { + views := warningViews([]string{"durability_uncertain", "audit_write_failed", "other"}) + if len(views) != 3 || views[0].Code != "durability_uncertain" || views[1].Code != "audit_write_failed" { + t.Fatalf("warning views = %#v", views) + } + if _, err := pathName("/api/vgs/", "/api/vgs/"); err == nil { + t.Fatal("empty path name accepted") + } + if _, err := pathName("/api/vgs/a/b", "/api/vgs/"); err == nil { + t.Fatal("nested path name accepted") + } +} + +func TestAdminUICertificateListAndDownload(t *testing.T) { + server, _, _, _ := newTestServer(t) + dir := t.TempDir() + server.options.Config.AdminUI.ClientCertDir = dir + if err := os.WriteFile(filepath.Join(dir, "admin-admin.crt"), []byte("CERTIFICATE"), 0o644); err != nil { //nolint:gosec // test fixture certificate + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "admin-admin.key"), []byte("PRIVATE KEY"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "ignore.txt"), []byte("ignore"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "server.key"), []byte("SERVER KEY"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "ca.crt"), []byte("CA"), 0o644); err != nil { //nolint:gosec // test fixture + t.Fatal(err) + } + caPath := filepath.Join(dir, "client-ca.crt") + if err := os.WriteFile(caPath, []byte("CLIENT CA"), 0o644); err != nil { //nolint:gosec // test fixture + t.Fatal(err) + } + server.options.Config.Listen.TLS.ClientCAFile = caPath + if err := os.Symlink(filepath.Join(dir, "admin-admin.crt"), filepath.Join(dir, "link.crt")); err != nil { + t.Fatal(err) + } + response := doRequest(t, server, http.MethodGet, "/api/certificates", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("list certificates = %d", response.Code) + } + var listing CertificatesResponse + if err := jsonUnmarshal(response.Body.Bytes(), &listing); err != nil { + t.Fatal(err) + } + if len(listing.Files) != 2 { + t.Fatalf("certificate files = %#v", listing.Files) + } + if listing.Directory != dir || listing.Files[0].Role != "admin" { + t.Fatalf("certificate listing = %#v", listing) + } + if listing.CA == nil || listing.CA.DownloadURL != "/api/ca" { + t.Fatalf("CA listing = %#v", listing.CA) + } + response = doRequest(t, server, http.MethodGet, "/api/certificates/admin-admin.crt", nil, nil) + if response.Code != http.StatusOK || response.Body.String() != "CERTIFICATE" { + t.Fatalf("download certificate = %d %q", response.Code, response.Body.String()) + } + if response.Header().Get("Content-Disposition") == "" { + t.Fatal("missing content disposition") + } + response = doRequest(t, server, http.MethodGet, "/api/ca", nil, nil) + if response.Code != http.StatusOK || response.Body.String() != "CLIENT CA" { + t.Fatalf("download CA = %d %q", response.Code, response.Body.String()) + } + for _, path := range []string{"/api/certificates/../admin-admin.crt", "/api/certificates/link.crt", "/api/certificates/ignore.txt", "/api/certificates/server.key", "/api/certificates/ca.crt"} { + response = doRequest(t, server, http.MethodGet, path, nil, nil) + if response.Code != http.StatusNotFound { + t.Fatalf("unsafe path %q = %d", path, response.Code) + } + } +} + +func TestAdminUICertificateErrorsAndISCSIDisabled(t *testing.T) { + server, _, _, _ := newTestServer(t) + server.options.Config.AdminUI.ClientCertDir = "" + server.options.Config.Listen.TLS.CertFile = filepath.Join(t.TempDir(), "server.crt") + response := doRequest(t, server, http.MethodGet, "/api/certificates", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("fallback cert dir = %d", response.Code) + } + var listing CertificatesResponse + if err := jsonUnmarshal(response.Body.Bytes(), &listing); err != nil { + t.Fatal(err) + } + if listing.Directory != filepath.Join(filepath.Dir(server.options.Config.Listen.TLS.CertFile), "clients") || listing.Error == "" { + t.Fatalf("fallback listing = %#v", listing) + } + response = doRequest(t, server, http.MethodGet, "/api/certificates/missing.crt", nil, nil) + if response.Code != http.StatusNotFound { + t.Fatalf("missing download = %d", response.Code) + } + fileDir := t.TempDir() + filePath := filepath.Join(fileDir, "not-a-dir") + if err := os.WriteFile(filePath, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + server.options.Config.AdminUI.ClientCertDir = filePath + response = doRequest(t, server, http.MethodGet, "/api/certificates", nil, nil) + if err := jsonUnmarshal(response.Body.Bytes(), &listing); err != nil { + t.Fatal(err) + } + if listing.Error == "" { + t.Fatal("non-directory client cert path was accepted") + } + server.options.Config.Targets.ISCSI.Enabled = false + response = doRequest(t, server, http.MethodGet, "/api/status", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("status = %d", response.Code) + } + var status StatusResponse + if err := jsonUnmarshal(response.Body.Bytes(), &status); err != nil { + t.Fatal(err) + } + if status.ISCSI.Enabled || len(status.ISCSI.Portals) != 1 { + t.Fatalf("disabled iSCSI view = %#v", status.ISCSI) + } + if roleFromCertificateName("unknown-file.crt") != "" { + t.Fatal("unknown role accepted") + } +} + +func TestAdminUITLSServerNames(t *testing.T) { + server, _, _, _ := newTestServer(t) + certPath := filepath.Join(t.TempDir(), "server.crt") + writeTestServerCertificate(t, certPath) + server.options.Config.Listen.TLS.CertFile = certPath + response := doRequest(t, server, http.MethodGet, "/api/config", nil, nil) + if response.Code != http.StatusOK { + t.Fatalf("config = %d", response.Code) + } + var current ConfigResponse + if err := jsonUnmarshal(response.Body.Bytes(), ¤t); err != nil { + t.Fatal(err) + } + if current.TLS.Error != "" || current.TLS.Subject != "kouja-agent" { + t.Fatalf("TLS info = %#v", current.TLS) + } + if len(current.TLS.ServerNames) != 1 || current.TLS.ServerNames[0] != "storage.example" || + len(current.TLS.IPAddresses) != 1 || current.TLS.IPAddresses[0] != "127.0.0.1" { + t.Fatalf("TLS names = %#v", current.TLS) + } +} + +func writeTestServerCertificate(t *testing.T, path string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "kouja-agent"}, + DNSNames: []string{"storage.example"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + encoded := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + if err := os.WriteFile(path, encoded, 0o600); err != nil { + t.Fatal(err) + } +} + +func TestAdminUITLSInfoErrors(t *testing.T) { + server, _, _, _ := newTestServer(t) + server.options.Config.Listen.TLS.CertFile = filepath.Join(t.TempDir(), "missing.crt") + server.options.Config.Listen.TLS.ClientCAFile = filepath.Join(t.TempDir(), "missing-ca.crt") + response := doRequest(t, server, http.MethodGet, "/api/config", nil, nil) + var current ConfigResponse + if err := jsonUnmarshal(response.Body.Bytes(), ¤t); err != nil { + t.Fatal(err) + } + if current.TLS.Error == "" { + t.Fatal("missing server certificate did not report a TLS error") + } + response = doRequest(t, server, http.MethodGet, "/api/ca", nil, nil) + if response.Code != http.StatusNotFound { + t.Fatalf("missing CA download = %d", response.Code) + } + response = doRequest(t, server, http.MethodGet, "/api/certificates", nil, nil) + var listing CertificatesResponse + if err := jsonUnmarshal(response.Body.Bytes(), &listing); err != nil { + t.Fatal(err) + } + if listing.CA != nil { + t.Fatalf("missing CA listed: %#v", listing.CA) + } +} diff --git a/internal/adminui/assets.go b/internal/adminui/assets.go new file mode 100644 index 0000000..246d65b --- /dev/null +++ b/internal/adminui/assets.go @@ -0,0 +1,52 @@ +package adminui + +import ( + "embed" + "io/fs" +) + +//go:embed all:assets +var embeddedAssets embed.FS + +//go:embed fallback.html +var fallbackHTML []byte + +type assetFS struct { + root fs.FS +} + +func defaultAssets() *assetFS { + root, err := fs.Sub(embeddedAssets, "assets") + if err != nil { + return &assetFS{root: embeddedAssets} + } + return &assetFS{root: root} +} + +func newAssetFS(root fs.FS) *assetFS { + return &assetFS{root: root} +} + +func (a *assetFS) read(name string) ([]byte, error) { + if a == nil || a.root == nil { + return nil, fs.ErrNotExist + } + return fs.ReadFile(a.root, name) +} + +func (a *assetFS) readAsset(name string) ([]byte, error) { + if a == nil || a.root == nil { + return nil, fs.ErrNotExist + } + if data, err := fs.ReadFile(a.root, "assets/"+name); err == nil { + return data, nil + } + return fs.ReadFile(a.root, name) +} + +func (a *assetFS) readFallback() ([]byte, error) { + if len(fallbackHTML) == 0 { + return nil, fs.ErrNotExist + } + return fallbackHTML, nil +} diff --git a/internal/adminui/assets/.gitignore b/internal/adminui/assets/.gitignore new file mode 100644 index 0000000..c3d07e5 --- /dev/null +++ b/internal/adminui/assets/.gitignore @@ -0,0 +1,3 @@ +* +!.gitkeep +!.gitignore diff --git a/internal/adminui/assets/.gitkeep b/internal/adminui/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/adminui/audit.go b/internal/adminui/audit.go new file mode 100644 index 0000000..170f8f5 --- /dev/null +++ b/internal/adminui/audit.go @@ -0,0 +1,28 @@ +package adminui + +import ( + "context" + "net" + "time" + + "github.com/google/uuid" + "github.com/voidstarit/kouja/internal/state" +) + +func (server *Server) writeAudit(ctx context.Context, action, operationID, preState, postState, result, errorCode, remote string) error { + if operationID == "" { + operationID = uuid.NewString() + } + sourceIP := remote + if host, _, err := net.SplitHostPort(remote); err == nil { + sourceIP = host + } + now := time.Now().UTC() + return server.options.Store.Transact(ctx, func(tx state.Tx) error { + return tx.PutAudit(state.Audit{ + Timestamp: now, Caller: "admin-ui", OperationID: operationID, + Action: action, PreState: preState, PostState: postState, + Result: result, ErrorCode: errorCode, SourceIP: sourceIP, + }) + }) +} diff --git a/internal/adminui/certificates.go b/internal/adminui/certificates.go new file mode 100644 index 0000000..de268cc --- /dev/null +++ b/internal/adminui/certificates.go @@ -0,0 +1,250 @@ +package adminui + +import ( + "crypto/x509" + "encoding/pem" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +var certificateFilePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*\.(crt|key|pem)$`) + +var sensitiveCertificateNames = map[string]struct{}{ + "ca.crt": {}, "ca.key": {}, "ca.srl": {}, + "server.crt": {}, "server.key": {}, + "client-ca.crt": {}, +} + +func allowedCertificateName(name string) bool { + if !certificateFilePattern.MatchString(name) || filepath.Base(name) != name { + return false + } + _, sensitive := sensitiveCertificateNames[strings.ToLower(name)] + return !sensitive +} + +func (server *Server) clientCertDir() string { + if dir := strings.TrimSpace(server.options.Config.AdminUI.ClientCertDir); dir != "" { + return dir + } + certFile := server.options.Config.Listen.TLS.CertFile + if certFile == "" { + return "" + } + return filepath.Join(filepath.Dir(certFile), "clients") +} + +func (server *Server) handleListCertificates(response http.ResponseWriter, _ *http.Request) { + dir := server.clientCertDir() + result := CertificatesResponse{Directory: dir, Files: []CertificateFile{}, CA: server.clientCA()} + if dir == "" { + result.Error = "client certificate directory is not configured" + writeJSON(response, http.StatusOK, result) + return + } + info, err := os.Lstat(dir) + if err != nil { + if os.IsNotExist(err) { + result.Error = "client certificate directory does not exist" + } else { + result.Error = err.Error() + } + writeJSON(response, http.StatusOK, result) + return + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + result.Error = "client certificate directory is not a regular directory" + writeJSON(response, http.StatusOK, result) + return + } + entries, err := os.ReadDir(dir) + if err != nil { + result.Error = err.Error() + writeJSON(response, http.StatusOK, result) + return + } + for _, entry := range entries { + if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + continue + } + name := entry.Name() + if !allowedCertificateName(name) { + continue + } + fileInfo, err := entry.Info() + if err != nil || !fileInfo.Mode().IsRegular() { + continue + } + kind := "certificate" + if strings.HasSuffix(name, ".key") { + kind = "private_key" + } + role := roleFromCertificateName(name) + if kind == "certificate" { + if parsedRole := roleFromCertificate(filepath.Join(dir, name)); parsedRole != "" { + role = parsedRole + } + } + result.Files = append(result.Files, CertificateFile{ + Name: name, Kind: kind, Role: role, SizeBytes: fileInfo.Size(), + ModifiedAt: formatTime(fileInfo.ModTime()), + DownloadURL: "/api/certificates/" + url.PathEscape(name), + }) + } + sort.Slice(result.Files, func(left, right int) bool { + return result.Files[left].Name < result.Files[right].Name + }) + writeJSON(response, http.StatusOK, result) +} + +func (server *Server) tlsInfo() TLSInfo { + info := TLSInfo{ServerNames: []string{}, IPAddresses: []string{}} + path := server.options.Config.Listen.TLS.CertFile + if path == "" { + info.Error = "server certificate path is not configured" + return info + } + data, err := os.ReadFile(path) //nolint:gosec // operator-configured server certificate path + if err != nil { + info.Error = err.Error() + return info + } + block, _ := pem.Decode(data) + if block == nil || block.Type != "CERTIFICATE" { + info.Error = "server certificate is not a PEM certificate" + return info + } + certificate, err := x509.ParseCertificate(block.Bytes) + if err != nil { + info.Error = err.Error() + return info + } + info.Subject = certificate.Subject.CommonName + info.ServerNames = append(info.ServerNames, certificate.DNSNames...) + for _, address := range certificate.IPAddresses { + info.IPAddresses = append(info.IPAddresses, address.String()) + } + info.NotAfter = formatTime(certificate.NotAfter) + if len(info.ServerNames) == 0 && len(info.IPAddresses) == 0 { + info.Error = "server certificate has no DNS or IP subject alternative names" + } + return info +} + +func (server *Server) clientCA() *CertificateFile { + path := server.options.Config.Listen.TLS.ClientCAFile + if path == "" { + return nil + } + info, err := os.Lstat(path) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil + } + return &CertificateFile{ + Name: filepath.Base(path), Kind: "certificate", Role: "ca", + SizeBytes: info.Size(), ModifiedAt: formatTime(info.ModTime()), + DownloadURL: "/api/ca", + } +} + +func (server *Server) handleDownloadCA(response http.ResponseWriter, request *http.Request) { + path := server.options.Config.Listen.TLS.ClientCAFile + if path == "" { + writeAPIError(response, http.StatusNotFound, "not_found", "client CA is not configured") + return + } + info, err := os.Lstat(path) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + writeAPIError(response, http.StatusNotFound, "not_found", "client CA is not available") + return + } + data, err := os.ReadFile(path) //nolint:gosec // operator-configured client CA path + if err != nil { + writeAPIError(response, http.StatusNotFound, "not_found", "client CA is not available") + return + } + name := filepath.Base(path) + response.Header().Set("Content-Type", "application/x-pem-file") + response.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name})) + response.Header().Set("Cache-Control", "no-store") + response.WriteHeader(http.StatusOK) + _, _ = response.Write(data) //nolint:gosec // serves the configured public CA certificate as a download + server.options.Logger.Info("client CA downloaded", "remote", request.RemoteAddr) +} + +func (server *Server) handleDownloadCertificate(response http.ResponseWriter, request *http.Request) { + name, err := pathName(request.URL.Path, "/api/certificates/") + if err != nil || !allowedCertificateName(name) { + writeAPIError(response, http.StatusNotFound, "not_found", "certificate not found") + return + } + dir := server.clientCertDir() + if dir == "" { + writeAPIError(response, http.StatusNotFound, "not_found", "certificate directory is not configured") + return + } + dirInfo, err := os.Lstat(dir) + if err != nil || dirInfo.Mode()&os.ModeSymlink != 0 || !dirInfo.IsDir() { + writeAPIError(response, http.StatusNotFound, "not_found", "certificate directory is not available") + return + } + path := filepath.Join(dir, name) + info, err := os.Lstat(path) //nolint:gosec // path is a validated basename under the configured client certificate directory + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + writeAPIError(response, http.StatusNotFound, "not_found", "certificate not found") + return + } + data, err := os.ReadFile(path) //nolint:gosec // path is constrained to the configured client certificate directory + if err != nil { + writeAPIError(response, http.StatusNotFound, "not_found", "certificate not found") + return + } + response.Header().Set("Content-Type", "application/x-pem-file") + response.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name})) + response.Header().Set("Cache-Control", "no-store") + response.WriteHeader(http.StatusOK) + _, _ = response.Write(data) //nolint:gosec // serves a validated client certificate/key file as a download + server.options.Logger.Info("client certificate downloaded", "name", name, "remote", request.RemoteAddr) +} + +func roleFromCertificateName(name string) string { + base := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(name, ".crt"), ".key"), ".pem") + role := strings.SplitN(base, "-", 2)[0] + switch role { + case "admin", "caller", "controller", "node": + return role + default: + return "" + } +} + +func roleFromCertificate(path string) string { + data, err := os.ReadFile(path) //nolint:gosec // path is constrained to the configured client certificate directory + if err != nil { + return "" + } + block, _ := pem.Decode(data) + if block == nil || block.Type != "CERTIFICATE" { + return "" + } + certificate, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "" + } + for _, uri := range certificate.URIs { + if uri.Scheme != "spiffe" || uri.Host != "kouja" { + continue + } + parts := strings.Split(strings.Trim(uri.Path, "/"), "/") + if len(parts) == 2 { + return parts[0] + } + } + return "" +} diff --git a/internal/adminui/config_file.go b/internal/adminui/config_file.go new file mode 100644 index 0000000..a9ba6b6 --- /dev/null +++ b/internal/adminui/config_file.go @@ -0,0 +1,401 @@ +package adminui + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net" + "os" + "reflect" + "sort" + "time" + + "github.com/voidstarit/kouja/internal/agent" + "github.com/voidstarit/kouja/internal/config" + "github.com/voidstarit/kouja/internal/fsutil" + "golang.org/x/sys/unix" + "gopkg.in/yaml.v3" +) + +type configFile struct { + Raw []byte + Parsed *config.Config + ETag string + DiskError string + DiskErrorCode string + Valid bool +} + +func readConfig(path string) (configFile, error) { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return configFile{ETag: "*", DiskErrorCode: "missing", DiskError: "configuration file does not exist"}, nil + } + if err != nil { + return configFile{DiskErrorCode: "read_error", DiskError: err.Error()}, nil + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return configFile{DiskErrorCode: "unsafe_path", DiskError: "configuration path is not a regular file"}, nil + } + raw, err := os.ReadFile(path) //nolint:gosec // operator-supplied configuration path + if err != nil { + return configFile{DiskErrorCode: "read_error", DiskError: err.Error()}, nil + } + etag := rawETag(raw) + parsed, err := config.Parse(raw) + if err != nil { + return configFile{Raw: raw, ETag: etag, DiskErrorCode: "parse_error", DiskError: err.Error()}, nil + } + return configFile{Raw: raw, Parsed: parsed, ETag: etag, Valid: true}, nil +} + +func rawETag(data []byte) string { + sum := sha256.Sum256(data) + return `"sha256:` + hex.EncodeToString(sum[:]) + `"` +} + +func normalizeConfig(input *config.Config, classes agent.ClassRegistry) *config.Config { + if input == nil { + return nil + } + copyOfConfig := *input + copyOfConfig.Storage.VGs = append([]string(nil), input.Storage.VGs...) + sort.Strings(copyOfConfig.Storage.VGs) + seen := make(map[string]struct{}, len(copyOfConfig.Storage.VGs)) + normalized := copyOfConfig.Storage.VGs[:0] + for _, vg := range copyOfConfig.Storage.VGs { + if _, ok := seen[vg]; ok { + continue + } + seen[vg] = struct{}{} + normalized = append(normalized, vg) + } + copyOfConfig.Storage.VGs = normalized + if classes != nil { + copyOfConfig.Storage.Classes = classes.Snapshot() + } else if input.Storage.Classes != nil { + copyOfConfig.Storage.Classes = make(map[string]string, len(input.Storage.Classes)) + for class, vg := range input.Storage.Classes { + copyOfConfig.Storage.Classes[class] = vg + } + } + return ©OfConfig +} + +func configEquivalent(running, candidate *config.Config, classes agent.ClassRegistry) bool { + if running == nil || candidate == nil { + return false + } + left, err := yaml.Marshal(normalizeConfig(running, classes)) + if err != nil { + return false + } + right, err := yaml.Marshal(normalizeConfig(candidate, classes)) + if err != nil { + return false + } + return string(left) == string(right) +} + +func bindingSocketChanged(running, candidate *config.Config) (bool, string) { + if running == nil || candidate == nil { + return false, "" + } + if running.Listen.Address != candidate.Listen.Address { + return true, "listen_address_changed" + } + if running.AdminUI.Enabled != candidate.AdminUI.Enabled { + return true, "admin_enabled_changed" + } + if (running.AdminUI.Enabled || candidate.AdminUI.Enabled) && + running.AdminUI.Address != candidate.AdminUI.Address { + return true, "admin_address_changed" + } + if running.Targets.ISCSI.Enabled != candidate.Targets.ISCSI.Enabled { + return true, "iscsi_enabled_changed" + } + if (running.Targets.ISCSI.Enabled || candidate.Targets.ISCSI.Enabled) && + !samePortalSet(running.Targets.ISCSI.Portals, candidate.Targets.ISCSI.Portals) { + return true, "iscsi_portals_changed" + } + if running.Targets.NVMeTCP.Enabled != candidate.Targets.NVMeTCP.Enabled { + return true, "nvme_tcp_enabled_changed" + } + if (running.Targets.NVMeTCP.Enabled || candidate.Targets.NVMeTCP.Enabled) && + !samePortalSet(running.Targets.NVMeTCP.Portals, candidate.Targets.NVMeTCP.Portals) { + return true, "nvme_tcp_portals_changed" + } + return false, "" +} + +func samePortalSet(left, right []config.Portal) bool { + if len(left) != len(right) { + return false + } + leftSet := make(map[string]int, len(left)) + for _, portal := range left { + leftSet[fmt.Sprintf("%s:%d", portal.Address, portal.Port)]++ + } + for _, portal := range right { + key := fmt.Sprintf("%s:%d", portal.Address, portal.Port) + leftSet[key]-- + } + for _, count := range leftSet { + if count != 0 { + return false + } + } + return true +} + +func (server *Server) configState() (ConfigResponse, error) { + file, err := readConfig(server.options.ConfigPath) + if err != nil { + return ConfigResponse{}, err + } + response := ConfigResponse{ + Path: server.options.ConfigPath, + BackupPath: server.options.ConfigPath + ".bak", + LastGoodPath: server.options.ConfigPath + ".last-good", + DiskYAML: string(file.Raw), + DiskETag: file.ETag, + DiskValid: file.Valid, + DiskError: file.DiskError, + DiskErrorCode: file.DiskErrorCode, + LastGoodError: server.lastGoodErrorValue(), + Warnings: []WarningView{}, + TLS: server.tlsInfo(), + } + running := normalizeConfig(server.options.Config, server.options.Classes) + runningYAML, err := yaml.Marshal(running) + if err != nil { + return ConfigResponse{}, err + } + response.RunningYAML = string(runningYAML) + if file.Valid && file.Parsed != nil { + response.ConfigChanged = !configEquivalent(server.options.Config, file.Parsed, server.options.Classes) + response.RestartRequired, response.RestartReason = bindingSocketChanged(server.options.Config, file.Parsed) + } else { + response.ConfigChanged = true + } + return response, nil +} + +func checkPreconditions(file configFile, ifMatch, ifNoneMatch string) error { + existing := file.DiskErrorCode != "missing" + if existing { + if ifMatch == "" { + if ifNoneMatch != "" { + return fmt.Errorf("%w: If-None-Match does not match an existing file", errConflict) + } + return fmt.Errorf("%w: If-Match is required", errPreconditionRequired) + } + if ifMatch != file.ETag { + return fmt.Errorf("%w: configuration changed", errConflict) + } + return nil + } + if ifNoneMatch != "*" { + if ifNoneMatch == "" { + if ifMatch != "" { + return fmt.Errorf("%w: If-Match does not match a missing file", errConflict) + } + return fmt.Errorf("%w: If-None-Match: * is required", errPreconditionRequired) + } + return fmt.Errorf("%w: invalid If-None-Match", errBadRequest) + } + return nil +} + +func (server *Server) saveConfig(ctx context.Context, body []byte, ifMatch, ifNoneMatch, remote string) (SaveConfigResponse, error) { + current, err := readConfig(server.options.ConfigPath) + if err != nil { + return SaveConfigResponse{}, err + } + if current.DiskErrorCode == "unsafe_path" { + return SaveConfigResponse{}, errUnsafePath + } + if current.DiskErrorCode == "read_error" { + return SaveConfigResponse{}, fmt.Errorf("%w: %s", errInternal, current.DiskError) + } + if err := checkPreconditions(current, ifMatch, ifNoneMatch); err != nil { + return SaveConfigResponse{}, err + } + candidate, err := config.Parse(body) + if err != nil { + return SaveConfigResponse{}, fmt.Errorf("%w: %v", errInvalidConfig, err) + } + preflightCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + warnings := server.preflight(preflightCtx, candidate) + cancel() + + lock, err := acquireFileLock(server.options.ConfigPath+".lock", 2*time.Second) + if err != nil { + return SaveConfigResponse{}, err + } + locked := true + defer func() { + if locked { + releaseFileLock(lock) + } + }() + rechecked, err := readConfig(server.options.ConfigPath) + if err != nil { + return SaveConfigResponse{}, err + } + if rechecked.ETag != current.ETag || rechecked.DiskErrorCode != current.DiskErrorCode { + return SaveConfigResponse{}, fmt.Errorf("%w: configuration changed while saving", errConflict) + } + if current.DiskErrorCode != "missing" { + if err := fsutil.WriteFileAtomic(server.options.ConfigPath+".bak", current.Raw, 0o600); err != nil { + return SaveConfigResponse{}, fmt.Errorf("%w: write backup: %v", errInternal, err) + } + } + if err := fsutil.WriteFileAtomic(server.options.ConfigPath, body, 0o600); err != nil { + if errors.Is(err, fsutil.ErrDurabilityUncertain) { + warnings = append(warnings, WarningView{Code: "durability_uncertain", Message: err.Error()}) + } else { + return SaveConfigResponse{}, fmt.Errorf("%w: write configuration: %v", errInternal, err) + } + } + releaseFileLock(lock) + locked = false + + state, err := server.configState() + if err != nil { + return SaveConfigResponse{}, err + } + response := SaveConfigResponse{ + Saved: true, Path: server.options.ConfigPath, + BackupPath: server.options.ConfigPath + ".bak", + DiskETag: state.DiskETag, ConfigChanged: state.ConfigChanged, + RestartRequired: state.RestartRequired, RestartReason: state.RestartReason, + Warnings: warnings, + } + auditErr := server.writeAudit(ctx, "config_save", "", "", "", "succeeded", "", remote) + if auditErr != nil { + response.Warnings = append(response.Warnings, WarningView{Code: "audit_write_failed", Message: auditErr.Error()}) + } + return response, nil +} + +func (server *Server) preflight(ctx context.Context, candidate *config.Config) []WarningView { + running := server.options.Config + warnings := make([]WarningView, 0) + add := func(code, message string) { + warnings = append(warnings, WarningView{Code: code, Message: message}) + } + if running == nil || candidate == nil { + return warnings + } + if running.SecretKeyFile != candidate.SecretKeyFile { + add("secret_key_changed", "secret_key_file changed; existing credentials may become unreadable") + } + if running.HostIDFile != candidate.HostIDFile || running.DataDir != candidate.DataDir || + running.EmergencyLog != candidate.EmergencyLog || running.State != candidate.State { + add("identity_or_state_path_changed", "host, data, emergency, or state paths changed; restart to apply") + } + if running.Listen.TLS != candidate.Listen.TLS { + add("listen_tls_changed", "listen.tls changed; restart to load new TLS material") + } + if running.AdminUI.Enabled != candidate.AdminUI.Enabled { + if !candidate.AdminUI.Enabled { + add("admin_ui_disabled", "admin UI will disappear after the operator restarts the service") + } + } + if running.AdminUI.Address != candidate.AdminUI.Address && (running.AdminUI.Enabled || candidate.AdminUI.Enabled) { + add("admin_address_changed", "admin UI moves after the operator restarts the service") + } + if running.AdminUI.ClientCertDir != candidate.AdminUI.ClientCertDir { + add("admin_client_cert_dir_changed", "client certificate directory changes apply after the operator restarts the service") + } + if running.Listen.Address != candidate.Listen.Address { + if err := probeAddress(candidate.Listen.Address); err != nil { + add("listen_address_unavailable", err.Error()) + } + } + if candidate.AdminUI.Enabled && running.AdminUI.Address != candidate.AdminUI.Address { + if err := probeAddress(candidate.AdminUI.Address); err != nil { + add("admin_address_unavailable", err.Error()) + } + } + for _, path := range []string{candidate.Listen.TLS.CertFile, candidate.Listen.TLS.KeyFile, candidate.Listen.TLS.ClientCAFile} { + if _, err := os.Stat(path); err != nil { + add("tls_file_missing", fmt.Sprintf("%s: %v", path, err)) + } + } + if server.options.VGs != nil { + for _, vg := range candidate.EffectiveVGs() { + if _, err := server.options.VGs.Inspect(ctx, vg); err != nil { + add("vg_unavailable", fmt.Sprintf("%s: %v", vg, err)) + } + } + } + if !reflect.DeepEqual(running.Targets.ISCSI.Portals, candidate.Targets.ISCSI.Portals) && + samePortalSet(running.Targets.ISCSI.Portals, candidate.Targets.ISCSI.Portals) { + add("portal_order_changed", "target portal order changed; future exports after restart use the new first portal") + } + if !reflect.DeepEqual(running.Targets.ISCSI, candidate.Targets.ISCSI) || + !reflect.DeepEqual(running.Targets.NVMeTCP, candidate.Targets.NVMeTCP) { + add("targets_changed", "target settings changed; review whether a manual restart is needed") + } + if !reflect.DeepEqual(running.Operations, candidate.Operations) || + !reflect.DeepEqual(running.Admission, candidate.Admission) || + running.Storage.HeadroomPercent != candidate.Storage.HeadroomPercent || + running.Storage.HeadroomMinBytes != candidate.Storage.HeadroomMinBytes || + running.State.ReconcileIntervalSeconds != candidate.State.ReconcileIntervalSeconds { + add("startup_bound_changed", "operations, admission, headroom, or interval settings changed; a manual restart may be needed") + } + if !reflect.DeepEqual(running.Storage.VGs, candidate.Storage.VGs) { + add("storage_vgs_changed", "storage.vgs is part of the first-start seed; use the live VG panel") + } + if !reflect.DeepEqual(running.Storage.Classes, candidate.Storage.Classes) { + add("storage_classes_changed", "raw storage.classes changes apply on restart; use the live class panel for immediate effect") + } + return warnings +} + +func probeAddress(address string) error { + listener, err := net.Listen("tcp", address) + if err != nil { + return fmt.Errorf("cannot bind %s: %w", address, err) + } + return listener.Close() +} + +func acquireFileLock(path string, timeout time.Duration) (*os.File, error) { + deadline := time.Now().Add(timeout) + for { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec // derived from the configuration path + if err != nil { + return nil, err + } + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err == nil { + return file, nil + } else if !errors.Is(err, unix.EWOULDBLOCK) && !errors.Is(err, unix.EAGAIN) { + _ = file.Close() + return nil, err + } + _ = file.Close() + if time.Now().After(deadline) { + return nil, errLocked + } + time.Sleep(25 * time.Millisecond) + } +} + +func releaseFileLock(file *os.File) { + if file == nil { + return + } + _ = unix.Flock(int(file.Fd()), unix.LOCK_UN) + _ = file.Close() +} + +func (server *Server) lastGoodErrorValue() string { + server.stateMu.Lock() + defer server.stateMu.Unlock() + return server.lastGoodError +} diff --git a/internal/adminui/fallback.html b/internal/adminui/fallback.html new file mode 100644 index 0000000..13d500b --- /dev/null +++ b/internal/adminui/fallback.html @@ -0,0 +1,12 @@ + + + + + kouja admin UI + + +

kouja admin UI

+

The browser assets are not built. Run make build or make ui.

+

The JSON API remains available under /api/.

+ + diff --git a/internal/adminui/metrics.go b/internal/adminui/metrics.go new file mode 100644 index 0000000..2339770 --- /dev/null +++ b/internal/adminui/metrics.go @@ -0,0 +1,210 @@ +package adminui + +import ( + "context" + "fmt" + "time" + + "github.com/voidstarit/kouja/internal/state" + "github.com/voidstarit/kouja/internal/target" +) + +const minimumMetricsInterval = time.Second + +type metricCounters struct { + readBytes int64 + writeBytes int64 + commands int64 +} + +type metricsSample struct { + at time.Time + targets map[string]metricCounters +} + +// buildMetrics collects target counters and derives rates from the previous +// successful sample. Metrics failures are reported in the response but do not +// fail /api/status or the data path. +func (server *Server) buildMetrics(ctx context.Context, volumes []state.VolumeStatus) MetricsView { + view := MetricsView{ + SampledAt: formatTime(server.options.Now()), + Connections: []ClientConnectionView{}, + Targets: []TargetMetricsView{}, + } + if server.options.TargetMetrics == nil { + view.Error = "live iSCSI metrics are not configured" + return view + } + raw, err := server.options.TargetMetrics.Metrics(ctx) + if err != nil { + view.Error = err.Error() + return view + } + view.Available = true + view.ConnectionsError = raw.ConnectionError + for _, connection := range raw.Connections { + view.Connections = append(view.Connections, ClientConnectionView{ + ClientAddress: connection.ClientAddress, + ClientPort: connection.ClientPort, + LocalAddress: connection.LocalAddress, + LocalPort: connection.LocalPort, + Target: connection.Target, + Initiator: connection.Initiator, + Ambiguous: connection.Ambiguous, + }) + } + + volumesByTarget := make(map[string]state.VolumeStatus, len(volumes)) + for _, volume := range volumes { + if volume.Export != nil && volume.Export.TargetName != "" { + volumesByTarget[volume.Export.TargetName] = volume + } + } + current := make(map[string]metricCounters, len(raw.Targets)) + for _, rawTarget := range raw.Targets { + targetView := targetMetricsView(rawTarget, volumesByTarget[rawTarget.Target]) + view.Targets = append(view.Targets, targetView) + current[rawTarget.Target] = metricCounters{ + readBytes: targetView.ReadBytes, writeBytes: targetView.WriteBytes, + commands: targetView.Commands, + } + } + + now := server.options.Now() + server.metricsMu.Lock() + previous := server.lastMetrics + ratesAvailable := previous != nil && now.After(previous.at) && + now.Sub(previous.at) >= minimumMetricsInterval + anyTargetRates := false + if ratesAvailable { + seconds := now.Sub(previous.at).Seconds() + for index := range view.Targets { + targetView := &view.Targets[index] + before, ok := previous.targets[targetView.Target] + if !ok { + targetView.RatesAvailable = false + continue + } + targetView.ReadBytesPerSecond, targetView.RatesAvailable = rate(before.readBytes, current[targetView.Target].readBytes, seconds) + var writeOK bool + targetView.WriteBytesPerSecond, writeOK = rate(before.writeBytes, current[targetView.Target].writeBytes, seconds) + targetView.RatesAvailable = targetView.RatesAvailable && writeOK + targetView.CommandsPerSecond, _ = rate(before.commands, current[targetView.Target].commands, seconds) + anyTargetRates = anyTargetRates || targetView.RatesAvailable + } + } + if previous == nil || ratesAvailable { + server.lastMetrics = &metricsSample{at: now, targets: current} + } + server.metricsMu.Unlock() + + view.SampledAt = formatTime(now) + view.RatesAvailable = ratesAvailable && anyTargetRates + for _, targetView := range view.Targets { + view.ReadBytes += targetView.ReadBytes + view.WriteBytes += targetView.WriteBytes + view.ReadBytesPerSecond += targetView.ReadBytesPerSecond + view.WriteBytesPerSecond += targetView.WriteBytesPerSecond + view.Commands += targetView.Commands + view.CommandsPerSecond += targetView.CommandsPerSecond + view.ErrorCount += targetView.ErrorCount + } + return view +} + +func targetMetricsView(raw target.ISCSITargetMetrics, volume state.VolumeStatus) TargetMetricsView { + view := TargetMetricsView{ + Target: raw.Target, Sessions: raw.Sessions, + Initiators: make([]InitiatorMetricsView, 0, len(raw.Initiators)), + LUNs: make([]LUNMetricsView, 0, len(raw.LUNs)), + ClientAddresses: append([]string{}, raw.ClientAddresses...), + Errors: metricsErrorsView(raw), + Error: raw.Error, + } + if volume.Export != nil { + view.VolumeOwner = volume.Owner + view.VolumeID = volume.VolumeID + } + for _, initiator := range raw.Initiators { + view.Initiators = append(view.Initiators, InitiatorMetricsView{ + Initiator: initiator.Initiator, + RXBytes: initiator.RXBytes, TXBytes: initiator.TXBytes, + CommandPDUs: initiator.CommandPDUs, ResponsePDUs: initiator.ResponsePDUs, + ConnectionTimeoutErrors: initiator.ConnectionTimeoutErrors, + ConnectionDigestErrors: initiator.ConnectionDigestErrors, + }) + view.ReadBytes += initiator.TXBytes + view.WriteBytes += initiator.RXBytes + } + for _, lun := range raw.LUNs { + view.LUNs = append(view.LUNs, LUNMetricsView{ + LUN: lun.LUN, ResourceUUID: lun.ResourceUUID, + Commands: lun.Commands, ReadBytes: lun.ReadBytes, WriteBytes: lun.WriteBytes, + Resets: lun.Resets, AbortsComplete: lun.AbortsComplete, AbortsNoTask: lun.AbortsNoTask, + Status: lun.Status, StateBit: lun.StateBit, BusyCount: lun.BusyCount, + InCommands: lun.InCommands, HighPriorityInCommands: lun.HighPriorityInCommands, + Error: lun.Error, + }) + view.Commands += lun.Commands + if len(raw.Initiators) == 0 { + view.ReadBytes += lun.ReadBytes + view.WriteBytes += lun.WriteBytes + } + if lun.Error != "" { + if view.Error != "" { + view.Error += "; " + } + view.Error += fmt.Sprintf("LUN %d: %s", lun.LUN, lun.Error) + } + } + if len(raw.LUNs) == 0 { + for _, initiator := range raw.Initiators { + view.Commands += initiator.CommandPDUs + } + } + view.ErrorCount = metricsErrorCount(view.Errors) + return view +} + +func metricsErrorsView(raw target.ISCSITargetMetrics) MetricsErrorsView { + errors := MetricsErrorsView{ + LoginFailures: raw.Login.TargetLoginFails, + AuthenticationFailures: raw.Login.AuthenticateFails, + AuthorizationFailures: raw.Login.AuthorizeFails, + NegotiationFailures: raw.Login.NegotiateFails, + OtherLoginFailures: raw.Login.OtherFails, + AbnormalLogouts: raw.Logout.Abnormal, + FormatErrors: raw.SessionErrors.FormatErrors, + ConnectionErrors: raw.SessionErrors.ConnectionErrors, + DigestErrors: raw.SessionErrors.DigestErrors, + } + for _, initiator := range raw.Initiators { + errors.ConnectionTimeouts += initiator.ConnectionTimeoutErrors + errors.ConnectionDigestErrors += initiator.ConnectionDigestErrors + } + for _, lun := range raw.LUNs { + errors.AbortsComplete += lun.AbortsComplete + errors.AbortsNoTask += lun.AbortsNoTask + errors.Resets += lun.Resets + } + return errors +} + +func metricsErrorCount(errors MetricsErrorsView) int64 { + loginFailures := errors.LoginFailures + if loginFailures == 0 { + loginFailures = errors.AuthenticationFailures + errors.AuthorizationFailures + + errors.NegotiationFailures + errors.OtherLoginFailures + } + return loginFailures + errors.AbnormalLogouts + errors.FormatErrors + + errors.ConnectionErrors + errors.DigestErrors + errors.ConnectionTimeouts + + errors.ConnectionDigestErrors + errors.AbortsComplete + errors.AbortsNoTask + + errors.Resets +} + +func rate(previous, current int64, seconds float64) (float64, bool) { + if previous < 0 || current < previous || seconds <= 0 { + return 0, false + } + return float64(current-previous) / seconds, true +} diff --git a/internal/adminui/server.go b/internal/adminui/server.go new file mode 100644 index 0000000..2ebdc74 --- /dev/null +++ b/internal/adminui/server.go @@ -0,0 +1,581 @@ +package adminui + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "mime" + "net" + "net/http" + "net/url" + "path" + "strings" + "sync" + "time" + + "github.com/voidstarit/kouja/internal/agent" + "github.com/voidstarit/kouja/internal/config" + "github.com/voidstarit/kouja/internal/fsutil" + "github.com/voidstarit/kouja/internal/lvm" + "github.com/voidstarit/kouja/internal/observability" + "github.com/voidstarit/kouja/internal/state" + "github.com/voidstarit/kouja/internal/target" +) + +var ( + errBadRequest = errors.New("adminui: bad request") + errTooLarge = errors.New("adminui: request body too large") + errConflict = errors.New("adminui: conflict") + errPreconditionRequired = errors.New("adminui: precondition required") + errInvalidConfig = errors.New("adminui: invalid configuration") + errUnsafePath = errors.New("adminui: unsafe path") + errInternal = errors.New("adminui: internal error") + errLocked = errors.New("adminui: configuration lock busy") + errStoreUnavailable = errors.New("adminui: state store unavailable") + errConfigMarshal = errors.New("adminui: running configuration marshal failed") +) + +type Store interface { + Transact(context.Context, func(state.Tx) error) error +} + +type CapacityProvider interface { + Capacity(context.Context, string) (lvm.Capacity, error) +} + +type TargetDisabler interface { + DisableTarget(context.Context, string, string) error +} + +type TargetEnabler interface { + EnableTarget(context.Context, string, string) error +} + +type Options struct { + ConfigPath string + Config *config.Config + ConfigData []byte + Store Store + Capacity CapacityProvider + VGs agent.VGService + Classes agent.ClassRegistry + Ready func() bool + HostUUID string + AgentVersion string + APIVersion string + StartedAt time.Time + Logger *slog.Logger + Now func() time.Time + Assets fs.FS + Listener net.Listener + Metrics *observability.Metrics + TargetMetrics target.ISCSIMetricsProvider + DisableTarget TargetDisabler + EnableTarget TargetEnabler + VGClassGate *sync.Mutex +} + +type Server struct { + options Options + httpServer *http.Server + listener net.Listener + allowedHosts map[string]struct{} + stateMu sync.Mutex + statusMu sync.Mutex + metricsMu sync.Mutex + lastMetrics *metricsSample + lastGoodError string + vgClassGate *sync.Mutex + assets *assetFS +} + +func New(options Options) (*Server, error) { + if options.ConfigPath == "" { + return nil, fmt.Errorf("adminui: ConfigPath is required") + } + if options.Config == nil || options.ConfigData == nil || options.Store == nil || + options.Capacity == nil || options.VGs == nil || options.Classes == nil { + return nil, fmt.Errorf("adminui: incomplete server dependencies") + } + if !options.Config.AdminUI.Enabled { + return nil, fmt.Errorf("adminui: admin UI is disabled") + } + if options.Logger == nil { + options.Logger = slog.Default() + } + if options.Now == nil { + options.Now = time.Now + } + if options.StartedAt.IsZero() { + options.StartedAt = time.Now() + } + if options.Ready == nil { + options.Ready = func() bool { return true } + } + if options.VGClassGate == nil { + options.VGClassGate = &sync.Mutex{} + } + listener := options.Listener + if listener == nil { + var err error + listener, err = net.Listen("tcp", options.Config.AdminUI.Address) + if err != nil { + return nil, fmt.Errorf("adminui: listen %s: %w", options.Config.AdminUI.Address, err) + } + } + if !isLoopbackListener(listener) { + _ = listener.Close() + return nil, fmt.Errorf("adminui: listener address is not loopback") + } + server := &Server{ + options: options, + listener: listener, + allowedHosts: make(map[string]struct{}), + vgClassGate: options.VGClassGate, + } + port := listenerPort(listener.Addr()) + for _, host := range []string{"127.0.0.1:" + port, "[::1]:" + port, "localhost:" + port} { + server.allowedHosts[host] = struct{}{} + } + if options.Assets != nil { + server.assets = newAssetFS(options.Assets) + } else { + server.assets = defaultAssets() + } + server.httpServer = &http.Server{ + Handler: server.Handler(), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 1 << 20, + } + return server, nil +} + +func (server *Server) Handler() http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + defer func() { + if recovered := recover(); recovered != nil { + server.options.Logger.Error("admin UI panic", "error", recovered) + writeAPIError(response, http.StatusInternalServerError, "internal_error", "internal error") + } + }() + server.securityHeaders(response) + if !server.allowedHost(request.Host) { + writeAPIError(response, http.StatusForbidden, "forbidden", "host is not allowed") + return + } + server.serveHTTP(response, request) + }) +} + +func (server *Server) Serve() error { + if server.options.Metrics != nil { + server.options.Metrics.Set("kouja_admin_ui_up", 1) + } + err := server.httpServer.Serve(server.listener) + if server.options.Metrics != nil { + server.options.Metrics.Set("kouja_admin_ui_up", 0) + } + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +func (server *Server) Shutdown(ctx context.Context) error { + if server.options.Metrics != nil { + server.options.Metrics.Set("kouja_admin_ui_up", 0) + } + return server.httpServer.Shutdown(ctx) +} + +func (server *Server) Address() net.Addr { + return server.listener.Addr() +} + +func (server *Server) MarkRunning() error { + err := fsutil.WriteFileAtomic(server.options.ConfigPath+".last-good", server.options.ConfigData, 0o600) + server.stateMu.Lock() + defer server.stateMu.Unlock() + if err != nil { + server.lastGoodError = err.Error() + return err + } + server.lastGoodError = "" + return nil +} + +func (server *Server) serveHTTP(response http.ResponseWriter, request *http.Request) { + switch { + case request.URL.Path == "/": + if request.Method != http.MethodGet { + methodNotAllowed(response) + return + } + server.serveIndex(response) + case request.URL.Path == "/favicon.ico": + if request.Method != http.MethodGet { + methodNotAllowed(response) + return + } + response.WriteHeader(http.StatusNoContent) + case strings.HasPrefix(request.URL.Path, "/assets/"): + if request.Method != http.MethodGet { + methodNotAllowed(response) + return + } + server.serveAsset(response, request) + case request.URL.Path == "/api/status": + if request.Method != http.MethodGet { + methodNotAllowed(response) + return + } + server.handleStatus(response, request) + case request.URL.Path == "/api/config": + switch request.Method { + case http.MethodGet: + server.handleGetConfig(response, request) + case http.MethodPut: + if !server.authorizeMutation(response, request, "application/yaml") { + return + } + server.handlePutConfig(response, request) + default: + methodNotAllowed(response) + } + case request.URL.Path == "/api/vgs": + switch request.Method { + case http.MethodGet: + server.handleListVGs(response, request) + case http.MethodPost: + if !server.authorizeMutation(response, request, "application/json") { + return + } + server.handleRegisterVG(response, request) + default: + methodNotAllowed(response) + } + case strings.HasPrefix(request.URL.Path, "/api/vgs/"): + if request.Method != http.MethodDelete { + methodNotAllowed(response) + return + } + if !server.authorizeMutation(response, request, "application/json") { + return + } + server.handleDeregisterVG(response, request) + case request.URL.Path == "/api/targets/disable": + if request.Method != http.MethodPost { + methodNotAllowed(response) + return + } + if !server.authorizeMutation(response, request, "application/json") { + return + } + server.handleDisableTarget(response, request) + case request.URL.Path == "/api/targets/enable": + if request.Method != http.MethodPost { + methodNotAllowed(response) + return + } + if !server.authorizeMutation(response, request, "application/json") { + return + } + server.handleEnableTarget(response, request) + case request.URL.Path == "/api/classes": + if request.Method != http.MethodGet { + methodNotAllowed(response) + return + } + server.handleListClasses(response, request) + case request.URL.Path == "/api/ca": + if request.Method != http.MethodGet { + methodNotAllowed(response) + return + } + server.handleDownloadCA(response, request) + case request.URL.Path == "/api/certificates": + if request.Method != http.MethodGet { + methodNotAllowed(response) + return + } + server.handleListCertificates(response, request) + case strings.HasPrefix(request.URL.Path, "/api/certificates/"): + if request.Method != http.MethodGet { + methodNotAllowed(response) + return + } + server.handleDownloadCertificate(response, request) + case strings.HasPrefix(request.URL.Path, "/api/classes/"): + if request.Method != http.MethodPut && request.Method != http.MethodDelete { + methodNotAllowed(response) + return + } + if !server.authorizeMutation(response, request, "application/json") { + return + } + if request.Method == http.MethodPut { + server.handleSetClass(response, request) + } else { + server.handleRemoveClass(response, request) + } + default: + writeAPIError(response, http.StatusNotFound, "not_found", "route not found") + } +} + +func (server *Server) handleStatus(response http.ResponseWriter, request *http.Request) { + server.statusMu.Lock() + defer server.statusMu.Unlock() + ctx, cancel := context.WithTimeout(request.Context(), 10*time.Second) + defer cancel() + status, err := server.buildStatus(ctx) + if err != nil { + if errors.Is(err, errConfigMarshal) { + writeAPIError(response, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + writeAPIError(response, http.StatusServiceUnavailable, "unavailable", err.Error()) + return + } + writeJSON(response, http.StatusOK, status) +} + +func (server *Server) handleGetConfig(response http.ResponseWriter, request *http.Request) { + configState, err := server.configState() + if err != nil { + writeAPIError(response, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + writeJSON(response, http.StatusOK, configState) +} + +func (server *Server) handlePutConfig(response http.ResponseWriter, request *http.Request) { + body, err := readLimitedBody(response, request, 1<<20) + if err != nil { + writeMappedError(response, err) + return + } + result, err := server.saveConfig(request.Context(), body, request.Header.Get("If-Match"), request.Header.Get("If-None-Match"), request.RemoteAddr) + if err != nil { + writeMappedError(response, err) + return + } + writeJSON(response, http.StatusOK, result) +} + +func (server *Server) handleListVGs(response http.ResponseWriter, request *http.Request) { + vgs, err := server.listVGStatuses(request.Context()) + if err != nil { + writeMappedError(response, err) + return + } + writeJSON(response, http.StatusOK, VGListResponse{VGs: vgs}) +} + +func (server *Server) handleListClasses(response http.ResponseWriter, _ *http.Request) { + classes := server.options.Classes.Snapshot() + if classes == nil { + classes = map[string]string{} + } + writeJSON(response, http.StatusOK, ClassListResponse{Classes: classes}) +} + +func (server *Server) allowedHost(host string) bool { + _, ok := server.allowedHosts[host] + if ok { + return true + } + // An IPv6 Host header may be formatted without brackets only in malformed + // requests; reject it rather than guessing. + return false +} + +func (server *Server) authorizeMutation(response http.ResponseWriter, request *http.Request, contentType string) bool { + if request.Header.Get("Sec-Fetch-Site") == "cross-site" { + writeAPIError(response, http.StatusForbidden, "forbidden", "cross-site request rejected") + return false + } + if origin := request.Header.Get("Origin"); origin != "" { + if origin == "null" { + writeAPIError(response, http.StatusForbidden, "forbidden", "null origin rejected") + return false + } + parsed, err := url.Parse(origin) + if err != nil || parsed.Scheme != "http" || parsed.Host == "" || !server.allowedHost(parsed.Host) { + writeAPIError(response, http.StatusForbidden, "forbidden", "origin is not allowed") + return false + } + } + if !strings.HasPrefix(request.Header.Get("Content-Type"), contentType) { + writeAPIError(response, http.StatusUnsupportedMediaType, "unsupported_media_type", "unexpected content type") + return false + } + return true +} + +func (server *Server) securityHeaders(response http.ResponseWriter) { + response.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") + response.Header().Set("X-Content-Type-Options", "nosniff") + response.Header().Set("X-Frame-Options", "DENY") + response.Header().Set("Referrer-Policy", "no-referrer") +} + +func (server *Server) serveIndex(response http.ResponseWriter) { + if server.assets == nil { + server.serveFallback(response) + return + } + data, err := server.assets.read("index.html") + if err != nil { + server.serveFallback(response) + return + } + response.Header().Set("Cache-Control", "no-store") + response.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = response.Write(data) //nolint:gosec // embedded, build-time HTML +} + +func (server *Server) serveFallback(response http.ResponseWriter) { + response.Header().Set("Content-Type", "text/html; charset=utf-8") + response.WriteHeader(http.StatusServiceUnavailable) + if server.assets != nil { + if data, err := server.assets.readFallback(); err == nil { + _, _ = response.Write(data) //nolint:gosec // embedded, build-time HTML + return + } + } + _, _ = io.WriteString(response, "kouja admin UI

UI assets are not built. Run make build.

") +} + +func (server *Server) serveAsset(response http.ResponseWriter, request *http.Request) { + relative := strings.TrimPrefix(request.URL.Path, "/assets/") + if relative == "" || strings.Contains(relative, "..") || strings.HasPrefix(path.Base(relative), ".") || strings.HasSuffix(relative, "/") { + writeAPIError(response, http.StatusNotFound, "not_found", "asset not found") + return + } + if server.assets == nil { + writeAPIError(response, http.StatusNotFound, "not_found", "asset not found") + return + } + data, err := server.assets.readAsset(relative) + if err != nil { + writeAPIError(response, http.StatusNotFound, "not_found", "asset not found") + return + } + contentType := mime.TypeByExtension(path.Ext(relative)) + if contentType == "" { + contentType = "application/octet-stream" + } + response.Header().Set("Content-Type", contentType) + response.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + _, _ = response.Write(data) //nolint:gosec // embedded, build-time HTML +} + +func newJSONEncoder(writer io.Writer) *json.Encoder { + encoder := json.NewEncoder(writer) + encoder.SetEscapeHTML(false) + return encoder +} + +func writeJSON(response http.ResponseWriter, status int, value any) { + response.Header().Set("Content-Type", "application/json") + response.Header().Set("Cache-Control", "no-store") + response.WriteHeader(status) + encoder := newJSONEncoder(response) + _ = encoder.Encode(value) +} + +func writeAPIError(response http.ResponseWriter, status int, code, message string) { + response.Header().Set("Content-Type", "application/json") + response.Header().Set("Cache-Control", "no-store") + response.WriteHeader(status) + encoder := newJSONEncoder(response) + _ = encoder.Encode(APIError{Error: message, Code: code}) +} + +func methodNotAllowed(response http.ResponseWriter) { + writeAPIError(response, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") +} + +func writeMappedError(response http.ResponseWriter, err error) { + switch { + case errors.Is(err, errBadRequest): + writeAPIError(response, http.StatusBadRequest, "bad_request", err.Error()) + case errors.Is(err, errTooLarge): + writeAPIError(response, http.StatusRequestEntityTooLarge, "too_large", err.Error()) + case errors.Is(err, errInvalidConfig): + writeAPIError(response, http.StatusUnprocessableEntity, "invalid_config", err.Error()) + case errors.Is(err, errUnsafePath): + writeAPIError(response, http.StatusUnprocessableEntity, "invalid_config", err.Error()) + case errors.Is(err, errConflict): + writeAPIError(response, http.StatusConflict, "conflict", err.Error()) + case errors.Is(err, errPreconditionRequired): + writeAPIError(response, http.StatusPreconditionRequired, "precondition_required", err.Error()) + case errors.Is(err, errLocked): + writeAPIError(response, http.StatusConflict, "conflict", err.Error()) + case errors.Is(err, errStoreUnavailable): + writeAPIError(response, http.StatusServiceUnavailable, "unavailable", err.Error()) + case errors.Is(err, agent.ErrVGInUse): + writeAPIError(response, http.StatusConflict, "vg_in_use", err.Error()) + case errors.Is(err, agent.ErrVGConflict): + writeAPIError(response, http.StatusConflict, "vg_conflict", err.Error()) + case errors.Is(err, agent.ErrVGClassMapped): + writeAPIError(response, http.StatusConflict, "class_conflict", err.Error()) + case errors.Is(err, agent.ErrVGUnavailable): + writeAPIError(response, http.StatusUnprocessableEntity, "vg_unavailable", err.Error()) + case errors.Is(err, agent.ErrVGUUIDMismatch): + writeAPIError(response, http.StatusUnprocessableEntity, "vg_unavailable", err.Error()) + case errors.Is(err, agent.ErrVGNotFound): + writeAPIError(response, http.StatusNotFound, "not_found", err.Error()) + case errors.Is(err, config.ErrInvalid): + writeAPIError(response, http.StatusUnprocessableEntity, "invalid_config", err.Error()) + case errors.Is(err, config.ErrClassNotFound): + writeAPIError(response, http.StatusNotFound, "not_found", err.Error()) + case errors.Is(err, config.ErrClassConflict): + writeAPIError(response, http.StatusConflict, "class_conflict", err.Error()) + case errors.Is(err, config.ErrLocked): + writeAPIError(response, http.StatusConflict, "conflict", err.Error()) + case errors.Is(err, config.ErrUnsafePath): + writeAPIError(response, http.StatusUnprocessableEntity, "invalid_config", err.Error()) + default: + writeAPIError(response, http.StatusInternalServerError, "internal_error", "internal error") + } +} + +func readLimitedBody(response http.ResponseWriter, request *http.Request, limit int64) ([]byte, error) { + request.Body = http.MaxBytesReader(response, request.Body, limit) + data, err := io.ReadAll(request.Body) + if err != nil { + if strings.Contains(err.Error(), "request body too large") { + return nil, errTooLarge + } + return nil, fmt.Errorf("%w: %v", errBadRequest, err) + } + return data, nil +} + +func isLoopbackListener(listener net.Listener) bool { + address, ok := listener.Addr().(*net.TCPAddr) + if !ok { + return false + } + return address.IP.Equal(net.ParseIP("127.0.0.1")) || address.IP.Equal(net.ParseIP("::1")) +} + +func listenerPort(address net.Addr) string { + if tcp, ok := address.(*net.TCPAddr); ok { + return fmt.Sprintf("%d", tcp.Port) + } + _, port, err := net.SplitHostPort(address.String()) + if err != nil { + return "" + } + return port +} diff --git a/internal/adminui/status.go b/internal/adminui/status.go new file mode 100644 index 0000000..60af8f1 --- /dev/null +++ b/internal/adminui/status.go @@ -0,0 +1,380 @@ +package adminui + +import ( + "context" + "errors" + "fmt" + "sort" + "sync" + "time" + + "github.com/voidstarit/kouja/internal/config" + "github.com/voidstarit/kouja/internal/lvm" + "github.com/voidstarit/kouja/internal/state" +) + +func (server *Server) buildStatus(ctx context.Context) (*StatusResponse, error) { + var vgs []state.VG + var volumes []state.VolumeStatus + var counts map[string]int + var operations []state.Operation + var operationCount int + err := server.options.Store.Transact(ctx, func(tx state.Tx) error { + values, err := tx.ListVGs() + if err != nil { + return err + } + vgs = values + volumes, err = tx.ListVolumeStatuses(256) + if err != nil { + return err + } + counts, err = tx.VolumeStateCounts() + if err != nil { + return err + } + operations, err = tx.ListRecentOperations(64) + if err != nil { + return err + } + operationCount, err = tx.OperationCount() + return err + }) + if err != nil { + return nil, fmt.Errorf("%w: %v", errStoreUnavailable, err) + } + configState, err := server.configState() + if err != nil { + return nil, fmt.Errorf("%w: %v", errConfigMarshal, err) + } + + configured := make(map[string]bool) + for _, name := range server.effectiveVGs() { + configured[name] = true + } + vgViews, capacityErrors := server.probeVGs(ctx, vgs, configured) + known := make(map[string]bool, len(vgViews)) + for _, vg := range vgViews { + known[vg.Name] = true + } + configuredNames := make([]string, 0, len(configured)) + for name := range configured { + configuredNames = append(configuredNames, name) + } + sort.Strings(configuredNames) + for _, name := range configuredNames { + if known[name] { + continue + } + vgViews = append(vgViews, VGStatus{ + Name: name, State: "not_registered", Configured: true, + StorageClass: server.options.Classes.ClassName(name), + Error: "configured VG is not registered", + }) + } + sort.Slice(vgViews, func(left, right int) bool { return vgViews[left].Name < vgViews[right].Name }) + + now := server.options.Now() + interval := time.Duration(server.options.Config.State.ReconcileIntervalSeconds) * time.Second + volumeViews := make([]VolumeStatus, 0, len(volumes)) + for _, volume := range volumes { + volumeViews = append(volumeViews, server.volumeView(volume, now, interval)) + } + totalVolumes := 0 + for _, count := range counts { + totalVolumes += count + } + operationViews := make([]OperationView, 0, len(operations)) + for _, operation := range operations { + operationViews = append(operationViews, operationView(operation)) + } + metrics := server.buildMetrics(ctx, volumes) + + status := &StatusResponse{ + Agent: AgentStatus{ + Version: server.options.AgentVersion, + APIVersion: server.options.APIVersion, + HostUUID: server.options.HostUUID, + Ready: server.options.Ready(), + StartedAt: formatTime(server.options.StartedAt), + UptimeSeconds: int64(now.Sub(server.options.StartedAt).Seconds()), + ConfigPath: server.options.ConfigPath, + ConfigChanged: configState.ConfigChanged, + RestartRequired: configState.RestartRequired, + RestartReason: configState.RestartReason, + LastGoodError: configState.LastGoodError, + ListenAddress: server.options.Config.Listen.Address, + AdminAddress: server.options.Config.AdminUI.Address, + Protocols: protocols(server.options.Config), + }, + ISCSI: iscsiView(server.options.Config), + Metrics: metrics, + VGs: vgViews, + Volumes: volumeViews, + Operations: operationViews, + Errors: capacityErrors, + Counts: Counts{ + Volumes: totalVolumes, + ByState: counts, + TruncatedVolumes: totalVolumes > len(volumeViews), + Operations: operationCount, + TruncatedOperations: operationCount > len(operationViews), + }, + } + if !configState.DiskValid { + code := "config_" + configState.DiskErrorCode + switch configState.DiskErrorCode { + case "parse_error": + code = "config_invalid" + case "unsafe_path": + code = "config_unsafe" + case "read_error": + code = "config_unreadable" + } + status.Errors = append(status.Errors, ErrorView{Code: code, Message: configState.DiskError}) + } + if status.Errors == nil { + status.Errors = []ErrorView{} + } + if status.Volumes == nil { + status.Volumes = []VolumeStatus{} + } + if status.Operations == nil { + status.Operations = []OperationView{} + } + return status, nil +} + +func (server *Server) listVGStatuses(ctx context.Context) ([]VGStatus, error) { + var vgs []state.VG + err := server.options.Store.Transact(ctx, func(tx state.Tx) error { + values, err := tx.ListVGs() + vgs = values + return err + }) + if err != nil { + return nil, fmt.Errorf("%w: %v", errStoreUnavailable, err) + } + configured := make(map[string]bool) + for _, name := range server.effectiveVGs() { + configured[name] = true + } + views, _ := server.probeVGs(ctx, vgs, configured) + known := make(map[string]bool, len(views)) + for _, vg := range views { + known[vg.Name] = true + } + for name := range configured { + if known[name] { + continue + } + views = append(views, VGStatus{ + Name: name, State: "not_registered", Configured: true, + StorageClass: server.options.Classes.ClassName(name), + Error: "configured VG is not registered", + }) + } + sort.Slice(views, func(left, right int) bool { return views[left].Name < views[right].Name }) + return views, nil +} + +func (server *Server) effectiveVGs() []string { + if server.options.Config == nil { + return nil + } + copyOfConfig := *server.options.Config + copyOfConfig.Storage.Classes = server.options.Classes.Snapshot() + return copyOfConfig.EffectiveVGs() +} + +func (server *Server) probeVGs(ctx context.Context, vgs []state.VG, configured map[string]bool) ([]VGStatus, []ErrorView) { + type result struct { + index int + capacity lvm.Capacity + err error + } + results := make(chan result, len(vgs)) + semaphore := make(chan struct{}, 4) + var group sync.WaitGroup + for index, vg := range vgs { + group.Add(1) + go func(index int, vg state.VG) { + defer group.Done() + select { + case semaphore <- struct{}{}: + case <-ctx.Done(): + results <- result{index: index, err: ctx.Err()} + return + } + defer func() { <-semaphore }() + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + capacity, err := server.options.Capacity.Capacity(probeCtx, vg.Name) + results <- result{index: index, capacity: capacity, err: err} + }(index, vg) + } + go func() { + group.Wait() + close(results) + }() + + capacities := make([]result, len(vgs)) + for result := range results { + capacities[result.index] = result + } + views := make([]VGStatus, 0, len(vgs)) + errorViews := make([]ErrorView, 0) + for index, vg := range vgs { + view := VGStatus{ + Name: vg.Name, UUID: vg.UUID, State: vg.State, + Configured: configured[vg.Name], + StorageClass: server.options.Classes.ClassName(vg.Name), + } + probe := capacities[index] + if probe.err != nil { + if errors.Is(probe.err, context.DeadlineExceeded) || errors.Is(probe.err, context.Canceled) { + view.State = "unknown" + view.Error = "capacity probe timed out" + errorViews = append(errorViews, ErrorView{Code: "capacity_unknown", Message: fmt.Sprintf("vg %s: %v", vg.Name, probe.err)}) + } else { + view.State = "unavailable" + view.Error = probe.err.Error() + errorViews = append(errorViews, ErrorView{Code: "capacity_unavailable", Message: fmt.Sprintf("vg %s: %v", vg.Name, probe.err)}) + } + views = append(views, view) + continue + } + if probe.capacity.VGState != "" { + view.State = probe.capacity.VGState + } + if probe.capacity.VGUUID != "" { + view.UUID = probe.capacity.VGUUID + } + if vg.UUID != "" && view.UUID != vg.UUID { + view.State = "unavailable" + view.Error = "observed VG UUID differs from the registered VG" + errorViews = append(errorViews, ErrorView{Code: "capacity_unavailable", Message: fmt.Sprintf("vg %s: UUID mismatch", vg.Name)}) + } + view.TotalBytes = probe.capacity.TotalBytes + view.FreeBytes = probe.capacity.FreeBytes + view.AllocatableBytes = probe.capacity.AllocatableBytes + view.ExtentSizeBytes = probe.capacity.ExtentSizeBytes + view.MaxVolumeBytes = probe.capacity.MaxVolumeBytes + view.ObservedAt = formatTime(probe.capacity.ObservedAt) + views = append(views, view) + } + if errorViews == nil { + errorViews = []ErrorView{} + } + return views, errorViews +} + +func (server *Server) volumeView(volume state.VolumeStatus, now time.Time, interval time.Duration) VolumeStatus { + view := VolumeStatus{ + Owner: volume.Owner, VolumeID: volume.VolumeID, ResourceUUID: volume.ResourceUUID, + LVName: volume.LVName, VGName: volume.VGName, + StorageClass: server.options.Classes.ClassName(volume.VGName), + RequestedSizeBytes: volume.RequestedSizeBytes, ActualSizeBytes: volume.ActualSizeBytes, + State: volume.State, UpdatedAt: formatTime(volume.UpdatedAt), + DriftReasons: append([]string(nil), volume.DriftReasons...), + LastErrorCode: volume.ErrorCode, + } + if view.DriftReasons == nil { + view.DriftReasons = []string{} + } + if volume.Export != nil { + view.Export = &ExportView{ + Protocol: volume.Export.Protocol, Initiator: volume.Export.Initiator, + TargetName: volume.Export.TargetName, LUN: volume.Export.LUN, NSID: volume.Export.NSID, + Portal: volume.Export.Portal, State: volume.Export.State, + } + } + observationFresh := false + if volume.Observation != nil { + stale := observationStale(now, interval, volume.Observation.ObservedAt) + observationFresh = !stale + view.Observation = &ObservationView{ + LVPresent: volume.Observation.LVPresent, ObservedSizeBytes: volume.Observation.ObservedSizeBytes, + DevicePath: volume.Observation.DevicePath, ObservedAt: formatTime(volume.Observation.ObservedAt), + Source: volume.Observation.Source, Stale: stale, + } + } + exportObservationFresh := false + if volume.ExportObservation != nil { + stale := observationStale(now, interval, volume.ExportObservation.ObservedAt) + exportObservationFresh = !stale + exportView := &ExportObservationView{ + TargetPresent: volume.ExportObservation.TargetPresent, + NamespacePresent: volume.ExportObservation.NamespacePresent, + ACLPresent: volume.ExportObservation.ACLPresent, + SessionState: volume.ExportObservation.SessionState, + ObservedAt: formatTime(volume.ExportObservation.ObservedAt), + Stale: stale, + InitiatorSource: "none", + } + if volume.ExportObservation.SessionState != "" && volume.ExportObservation.SessionState != "unknown" { + active := volume.ExportObservation.ActiveSessions + exportView.ActiveSessions = &active + } + if volume.ExportObservation.ObservedInitiator != "" { + initiator := volume.ExportObservation.ObservedInitiator + exportView.ObservedInitiator = &initiator + exportView.InitiatorSource = "acl" + } + view.ExportObservation = exportView + } + view.Serving = volume.State == "exported" && + view.Export != nil && view.Export.State == "exported" && + view.Observation != nil && view.Observation.LVPresent && observationFresh && + view.ExportObservation != nil && view.ExportObservation.TargetPresent && + view.ExportObservation.ACLPresent && exportObservationFresh && + (view.Export.Protocol != "nvme-tcp" || view.ExportObservation.NamespacePresent) + return view +} + +func observationStale(now time.Time, interval time.Duration, observedAt time.Time) bool { + if observedAt.IsZero() || interval <= 0 { + return true + } + return now.Sub(observedAt) > 2*interval +} + +func operationView(operation state.Operation) OperationView { + return OperationView{ + ID: operation.ID, Owner: operation.Owner, VolumeID: operation.VolumeID, + ResourceUUID: operation.ResourceUUID, Action: operation.Action, State: operation.State, + Attempt: operation.Attempt, MaxAttempts: operation.MaxAttempts, + DeadlineAt: formatTime(operation.DeadlineAt), LastErrorCode: operation.LastErrorCode, + LastErrorRetryable: operation.LastErrorRetryable, + CreatedAt: formatTime(operation.CreatedAt), UpdatedAt: formatTime(operation.UpdatedAt), + } +} + +func iscsiView(cfg *config.Config) ISCSIInfo { + info := ISCSIInfo{ + Enabled: cfg.Targets.ISCSI.Enabled, IQNPrefix: cfg.Targets.ISCSI.IQNPrefix, + Portals: make([]string, 0, len(cfg.Targets.ISCSI.Portals)), + } + for _, portal := range cfg.Targets.ISCSI.Portals { + info.Portals = append(info.Portals, fmt.Sprintf("%s:%d", portal.Address, portal.Port)) + } + return info +} + +func protocols(cfg *config.Config) []string { + result := make([]string, 0, 2) + if cfg.Targets.ISCSI.Enabled { + result = append(result, "iscsi") + } + if cfg.Targets.NVMeTCP.Enabled { + result = append(result, "nvme-tcp") + } + return result +} + +func formatTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339Nano) +} diff --git a/internal/adminui/targets.go b/internal/adminui/targets.go new file mode 100644 index 0000000..10dbbc8 --- /dev/null +++ b/internal/adminui/targets.go @@ -0,0 +1,134 @@ +package adminui + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/voidstarit/kouja/internal/operations" + "github.com/voidstarit/kouja/internal/state" + "github.com/voidstarit/kouja/internal/target" +) + +func (server *Server) handleDisableTarget(response http.ResponseWriter, request *http.Request) { + owner, volumeID, ok := server.readTargetAction(response, request) + if !ok { + return + } + export, ok := server.targetActionDefinition(response, request, owner, volumeID) + if !ok { + return + } + if export.Protocol != "iscsi" { + writeAPIError(response, http.StatusUnprocessableEntity, "invalid_target", "only iSCSI targets can be disabled here") + return + } + if server.options.DisableTarget == nil { + writeAPIError(response, http.StatusServiceUnavailable, "unavailable", "target disable is not configured") + return + } + if err := server.options.DisableTarget.DisableTarget(request.Context(), owner, volumeID); err != nil { + writeTargetActionError(response, err) + return + } + if auditErr := server.writeAudit(request.Context(), "target_disable", "", "", "unexported", "succeeded", "", request.RemoteAddr); auditErr != nil { + server.options.Logger.Warn("admin UI audit write failed", "action", "target_disable", "error", auditErr) + } + response.WriteHeader(http.StatusNoContent) +} + +func (server *Server) handleEnableTarget(response http.ResponseWriter, request *http.Request) { + owner, volumeID, ok := server.readTargetAction(response, request) + if !ok { + return + } + export, ok := server.targetActionDefinition(response, request, owner, volumeID) + if !ok { + return + } + if export.Protocol != "iscsi" { + writeAPIError(response, http.StatusUnprocessableEntity, "invalid_target", "only iSCSI targets can be enabled here") + return + } + if server.options.EnableTarget == nil { + writeAPIError(response, http.StatusServiceUnavailable, "unavailable", "target enable is not configured") + return + } + if err := server.options.EnableTarget.EnableTarget(request.Context(), owner, volumeID); err != nil { + writeTargetActionError(response, err) + return + } + if auditErr := server.writeAudit(request.Context(), "target_enable", "", "", "exported", "succeeded", "", request.RemoteAddr); auditErr != nil { + server.options.Logger.Warn("admin UI audit write failed", "action", "target_enable", "error", auditErr) + } + response.WriteHeader(http.StatusNoContent) +} + +func (server *Server) readTargetAction(response http.ResponseWriter, request *http.Request) (string, string, bool) { + body, err := readLimitedBody(response, request, 1<<20) + if err != nil { + writeMappedError(response, err) + return "", "", false + } + var payload struct { + Owner string `json:"owner"` + VolumeID string `json:"volume_id"` + } + if err := decodeJSON(body, &payload); err != nil { + writeMappedError(response, fmt.Errorf("%w: %v", errBadRequest, err)) + return "", "", false + } + if payload.Owner == "" || payload.VolumeID == "" { + writeMappedError(response, fmt.Errorf("%w: owner and volume_id are required", errBadRequest)) + return "", "", false + } + return payload.Owner, payload.VolumeID, true +} + +func (server *Server) targetActionDefinition(response http.ResponseWriter, request *http.Request, owner, volumeID string) (state.Export, bool) { + var export state.Export + err := server.options.Store.Transact(request.Context(), func(tx state.Tx) error { + value, err := tx.GetExport(owner, volumeID) + if err != nil { + return err + } + export = value + return nil + }) + if errors.Is(err, state.ErrNotFound) { + writeAPIError(response, http.StatusNotFound, "not_found", "target is not defined") + return state.Export{}, false + } + if err != nil { + writeAPIError(response, http.StatusInternalServerError, "internal_error", "could not read target definition") + return state.Export{}, false + } + return export, true +} + +func writeTargetActionError(response http.ResponseWriter, err error) { + switch { + case errors.Is(err, state.ErrNotFound), errors.Is(err, target.ErrNotFound): + writeAPIError(response, http.StatusNotFound, "not_found", "target is not defined") + case errors.Is(err, operations.ErrVolumeInDrift): + writeAPIError(response, http.StatusConflict, "volume_in_drift", "volume is in drift; resolve it before changing the target") + case errors.Is(err, operations.ErrSessionActive), errors.Is(err, target.ErrSessionActive): + writeAPIError(response, http.StatusConflict, "session_active", "active session could not be dropped") + case errors.Is(err, operations.ErrSessionUnknown), errors.Is(err, target.ErrSessionUnknown): + writeAPIError(response, http.StatusConflict, "session_unknown", "session state is unknown") + case errors.Is(err, operations.ErrOperationInProgress): + writeAPIError(response, http.StatusConflict, "operation_in_progress", "another operation is in progress") + case errors.Is(err, operations.ErrVolumeAlreadyExport): + writeAPIError(response, http.StatusConflict, "already_exported", "target is already exported") + case errors.Is(err, operations.ErrInvalidRequest), errors.Is(err, operations.ErrUnsupportedAction), + errors.Is(err, target.ErrInvalid), errors.Is(err, state.ErrInvalid): + writeAPIError(response, http.StatusUnprocessableEntity, "invalid_target", err.Error()) + case errors.Is(err, target.ErrConflict), errors.Is(err, state.ErrConflict): + writeAPIError(response, http.StatusConflict, "conflict", "target changed while applying the action") + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + writeAPIError(response, http.StatusGatewayTimeout, "timeout", "target action timed out") + default: + writeAPIError(response, http.StatusInternalServerError, "internal_error", "could not change target state") + } +} diff --git a/internal/adminui/types.go b/internal/adminui/types.go new file mode 100644 index 0000000..a7fe7be --- /dev/null +++ b/internal/adminui/types.go @@ -0,0 +1,303 @@ +package adminui + +type WarningView struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type APIError struct { + Error string `json:"error"` + Code string `json:"code"` +} + +type StatusResponse struct { + Agent AgentStatus `json:"agent"` + ISCSI ISCSIInfo `json:"iscsi"` + Metrics MetricsView `json:"metrics"` + VGs []VGStatus `json:"vgs"` + Volumes []VolumeStatus `json:"volumes"` + Counts Counts `json:"counts"` + Operations []OperationView `json:"operations"` + Errors []ErrorView `json:"errors"` +} + +type MetricsView struct { + SampledAt string `json:"sampled_at"` + Available bool `json:"available"` + RatesAvailable bool `json:"rates_available"` + Error string `json:"error"` + ReadBytes int64 `json:"read_bytes"` + WriteBytes int64 `json:"write_bytes"` + ReadBytesPerSecond float64 `json:"read_bytes_per_second"` + WriteBytesPerSecond float64 `json:"write_bytes_per_second"` + Commands int64 `json:"commands"` + CommandsPerSecond float64 `json:"commands_per_second"` + ErrorCount int64 `json:"error_count"` + Connections []ClientConnectionView `json:"connections"` + ConnectionsError string `json:"connections_error"` + Targets []TargetMetricsView `json:"targets"` +} + +type ClientConnectionView struct { + ClientAddress string `json:"client_address"` + ClientPort int `json:"client_port"` + LocalAddress string `json:"local_address"` + LocalPort int `json:"local_port"` + Target string `json:"target"` + Initiator string `json:"initiator"` + Ambiguous bool `json:"ambiguous"` +} + +type TargetMetricsView struct { + Target string `json:"target"` + VolumeOwner string `json:"volume_owner"` + VolumeID string `json:"volume_id"` + Sessions int64 `json:"sessions"` + Initiators []InitiatorMetricsView `json:"initiators"` + LUNs []LUNMetricsView `json:"luns"` + ClientAddresses []string `json:"client_addresses"` + ReadBytes int64 `json:"read_bytes"` + WriteBytes int64 `json:"write_bytes"` + ReadBytesPerSecond float64 `json:"read_bytes_per_second"` + WriteBytesPerSecond float64 `json:"write_bytes_per_second"` + Commands int64 `json:"commands"` + CommandsPerSecond float64 `json:"commands_per_second"` + RatesAvailable bool `json:"rates_available"` + ErrorCount int64 `json:"error_count"` + Errors MetricsErrorsView `json:"errors"` + Error string `json:"error"` +} + +type InitiatorMetricsView struct { + Initiator string `json:"initiator"` + RXBytes int64 `json:"rx_bytes"` + TXBytes int64 `json:"tx_bytes"` + CommandPDUs int64 `json:"command_pdus"` + ResponsePDUs int64 `json:"response_pdus"` + ConnectionTimeoutErrors int64 `json:"connection_timeout_errors"` + ConnectionDigestErrors int64 `json:"connection_digest_errors"` +} + +type LUNMetricsView struct { + LUN int32 `json:"lun"` + ResourceUUID string `json:"resource_uuid"` + Commands int64 `json:"commands"` + ReadBytes int64 `json:"read_bytes"` + WriteBytes int64 `json:"write_bytes"` + Resets int64 `json:"resets"` + AbortsComplete int64 `json:"aborts_complete"` + AbortsNoTask int64 `json:"aborts_no_task"` + Status string `json:"status"` + StateBit string `json:"state_bit"` + BusyCount int64 `json:"busy_count"` + InCommands int64 `json:"in_commands"` + HighPriorityInCommands int64 `json:"high_priority_in_commands"` + Error string `json:"error"` +} + +type MetricsErrorsView struct { + LoginFailures int64 `json:"login_failures"` + AuthenticationFailures int64 `json:"authentication_failures"` + AuthorizationFailures int64 `json:"authorization_failures"` + NegotiationFailures int64 `json:"negotiation_failures"` + OtherLoginFailures int64 `json:"other_login_failures"` + AbnormalLogouts int64 `json:"abnormal_logouts"` + FormatErrors int64 `json:"format_errors"` + ConnectionErrors int64 `json:"connection_errors"` + DigestErrors int64 `json:"digest_errors"` + ConnectionTimeouts int64 `json:"connection_timeouts"` + ConnectionDigestErrors int64 `json:"connection_digest_errors"` + AbortsComplete int64 `json:"aborts_complete"` + AbortsNoTask int64 `json:"aborts_no_task"` + Resets int64 `json:"resets"` +} + +type ISCSIInfo struct { + Enabled bool `json:"enabled"` + IQNPrefix string `json:"iqn_prefix"` + Portals []string `json:"portals"` +} + +type AgentStatus struct { + Version string `json:"version"` + APIVersion string `json:"api_version"` + HostUUID string `json:"host_uuid"` + Ready bool `json:"ready"` + StartedAt string `json:"started_at"` + UptimeSeconds int64 `json:"uptime_seconds"` + ConfigPath string `json:"config_path"` + ConfigChanged bool `json:"config_changed"` + RestartRequired bool `json:"restart_required"` + RestartReason string `json:"restart_reason"` + LastGoodError string `json:"last_good_error"` + ListenAddress string `json:"listen_address"` + AdminAddress string `json:"admin_address"` + Protocols []string `json:"protocols"` +} + +type VGStatus struct { + Name string `json:"name"` + UUID string `json:"uuid"` + State string `json:"state"` + StorageClass string `json:"storage_class"` + Configured bool `json:"configured"` + TotalBytes int64 `json:"total_bytes"` + FreeBytes int64 `json:"free_bytes"` + AllocatableBytes int64 `json:"allocatable_bytes"` + ExtentSizeBytes int64 `json:"extent_size_bytes"` + MaxVolumeBytes int64 `json:"max_volume_bytes"` + ObservedAt string `json:"observed_at"` + Error string `json:"error"` +} + +type VolumeStatus struct { + Owner string `json:"owner"` + VolumeID string `json:"volume_id"` + ResourceUUID string `json:"resource_uuid"` + LVName string `json:"lv_name"` + VGName string `json:"vg_name"` + StorageClass string `json:"storage_class"` + RequestedSizeBytes int64 `json:"requested_size_bytes"` + ActualSizeBytes int64 `json:"actual_size_bytes"` + State string `json:"state"` + Serving bool `json:"serving"` + DriftReasons []string `json:"drift_reasons"` + UpdatedAt string `json:"updated_at"` + Export *ExportView `json:"export"` + Observation *ObservationView `json:"observation"` + ExportObservation *ExportObservationView `json:"export_observation"` + LastErrorCode string `json:"last_error_code,omitempty"` +} + +type ExportView struct { + Protocol string `json:"protocol"` + Initiator string `json:"initiator"` + TargetName string `json:"target_name"` + LUN int32 `json:"lun"` + NSID int32 `json:"nsid"` + Portal string `json:"portal"` + State string `json:"state"` +} + +type ObservationView struct { + LVPresent bool `json:"lv_present"` + ObservedSizeBytes int64 `json:"observed_size_bytes"` + DevicePath string `json:"device_path"` + ObservedAt string `json:"observed_at"` + Source string `json:"source"` + Stale bool `json:"stale"` +} + +type ExportObservationView struct { + TargetPresent bool `json:"target_present"` + NamespacePresent bool `json:"namespace_present"` + ACLPresent bool `json:"acl_present"` + ActiveSessions *int32 `json:"active_sessions"` + SessionState string `json:"session_state"` + ObservedInitiator *string `json:"observed_initiator"` + InitiatorSource string `json:"initiator_source"` + ObservedAt string `json:"observed_at"` + Stale bool `json:"stale"` +} + +type Counts struct { + Volumes int `json:"volumes"` + ByState map[string]int `json:"by_state"` + TruncatedVolumes bool `json:"truncated_volumes"` + Operations int `json:"operations"` + TruncatedOperations bool `json:"truncated_operations"` +} + +type OperationView struct { + ID string `json:"id"` + Owner string `json:"owner"` + VolumeID string `json:"volume_id"` + ResourceUUID string `json:"resource_uuid"` + Action string `json:"action"` + State string `json:"state"` + Attempt int `json:"attempt"` + MaxAttempts int `json:"max_attempts"` + DeadlineAt string `json:"deadline_at"` + LastErrorCode string `json:"last_error_code"` + LastErrorRetryable bool `json:"last_error_retryable"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type ErrorView struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type ConfigResponse struct { + Path string `json:"path"` + BackupPath string `json:"backup_path"` + LastGoodPath string `json:"last_good_path"` + DiskYAML string `json:"disk_yaml"` + RunningYAML string `json:"running_yaml"` + DiskETag string `json:"disk_etag"` + DiskValid bool `json:"disk_valid"` + DiskError string `json:"disk_error"` + DiskErrorCode string `json:"disk_error_code"` + ConfigChanged bool `json:"config_changed"` + RestartRequired bool `json:"restart_required"` + RestartReason string `json:"restart_reason"` + LastGoodError string `json:"last_good_error"` + Warnings []WarningView `json:"warnings"` + TLS TLSInfo `json:"tls"` +} + +type TLSInfo struct { + Subject string `json:"subject"` + ServerNames []string `json:"server_names"` + IPAddresses []string `json:"ip_addresses"` + NotAfter string `json:"not_after"` + Error string `json:"error"` +} + +type SaveConfigResponse struct { + Saved bool `json:"saved"` + Path string `json:"path"` + BackupPath string `json:"backup_path"` + DiskETag string `json:"disk_etag"` + ConfigChanged bool `json:"config_changed"` + RestartRequired bool `json:"restart_required"` + RestartReason string `json:"restart_reason"` + Warnings []WarningView `json:"warnings"` +} + +type VGListResponse struct { + VGs []VGStatus `json:"vgs"` +} + +type RegisterVGResponse struct { + VG VGStatus `json:"vg"` + Warnings []WarningView `json:"warnings"` +} + +type ClassListResponse struct { + Classes map[string]string `json:"classes"` +} + +type SetClassResponse struct { + Classes map[string]string `json:"classes"` + ConfigChanged bool `json:"config_changed"` + RestartRequired bool `json:"restart_required"` + Warnings []WarningView `json:"warnings"` +} + +type CertificateFile struct { + Name string `json:"name"` + Kind string `json:"kind"` + Role string `json:"role"` + SizeBytes int64 `json:"size_bytes"` + ModifiedAt string `json:"modified_at"` + DownloadURL string `json:"download_url"` +} + +type CertificatesResponse struct { + Directory string `json:"directory"` + Files []CertificateFile `json:"files"` + CA *CertificateFile `json:"ca"` + Error string `json:"error"` +} diff --git a/internal/adminui/vg.go b/internal/adminui/vg.go new file mode 100644 index 0000000..505224d --- /dev/null +++ b/internal/adminui/vg.go @@ -0,0 +1,200 @@ +package adminui + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/voidstarit/kouja/internal/agent" + "github.com/voidstarit/kouja/internal/lvm" +) + +func (server *Server) handleRegisterVG(response http.ResponseWriter, request *http.Request) { + body, err := readLimitedBody(response, request, 1<<20) + if err != nil { + writeMappedError(response, err) + return + } + var payload struct { + Name string `json:"name"` + } + if err := decodeJSON(body, &payload); err != nil { + writeMappedError(response, fmt.Errorf("%w: %v", errBadRequest, err)) + return + } + if payload.Name == "" { + writeMappedError(response, fmt.Errorf("%w: name is required", errBadRequest)) + return + } + server.vgClassGate.Lock() + defer server.vgClassGate.Unlock() + capacity, err := server.options.VGs.Register(request.Context(), payload.Name, "", false) + if err != nil { + writeMappedError(response, err) + return + } + view := server.buildVGStatusFromCapacity(payload.Name, capacity, server.configuredVG(payload.Name), "") + warnings := []WarningView{} + if auditErr := server.writeAudit(request.Context(), "vg_register", "", "", "", "succeeded", "", request.RemoteAddr); auditErr != nil { + warnings = append(warnings, WarningView{Code: "audit_write_failed", Message: auditErr.Error()}) + } + writeJSON(response, http.StatusCreated, RegisterVGResponse{VG: view, Warnings: warnings}) +} + +func (server *Server) handleDeregisterVG(response http.ResponseWriter, request *http.Request) { + name, err := pathName(request.URL.Path, "/api/vgs/") + if err != nil { + writeMappedError(response, err) + return + } + server.vgClassGate.Lock() + defer server.vgClassGate.Unlock() + if err := server.options.VGs.Deregister(request.Context(), name); err != nil { + writeMappedError(response, err) + return + } + if auditErr := server.writeAudit(request.Context(), "vg_deregister", "", "", "", "succeeded", "", request.RemoteAddr); auditErr != nil { + server.options.Logger.Warn("admin UI audit write failed", "action", "vg_deregister", "error", auditErr) + } + response.WriteHeader(http.StatusNoContent) +} + +func (server *Server) handleSetClass(response http.ResponseWriter, request *http.Request) { + class, err := pathName(request.URL.Path, "/api/classes/") + if err != nil { + writeMappedError(response, err) + return + } + body, err := readLimitedBody(response, request, 1<<20) + if err != nil { + writeMappedError(response, err) + return + } + var payload struct { + VG string `json:"vg"` + } + if err := decodeJSON(body, &payload); err != nil { + writeMappedError(response, fmt.Errorf("%w: %v", errBadRequest, err)) + return + } + if payload.VG == "" { + writeMappedError(response, fmt.Errorf("%w: vg is required", errBadRequest)) + return + } + server.vgClassGate.Lock() + defer server.vgClassGate.Unlock() + vgs, err := server.options.VGs.List(request.Context()) + if err != nil { + writeMappedError(response, err) + return + } + found := false + for _, vg := range vgs { + if vg.Name == payload.VG { + found = true + break + } + } + if !found { + writeMappedError(response, fmt.Errorf("%w: VG %q is not registered", agent.ErrVGUnavailable, payload.VG)) + return + } + warnings, err := server.options.Classes.Set(request.Context(), class, payload.VG) + if err != nil { + writeMappedError(response, err) + return + } + configState, err := server.configState() + if err != nil { + writeMappedError(response, fmt.Errorf("%w: %v", errInternal, err)) + return + } + result := SetClassResponse{ + Classes: server.options.Classes.Snapshot(), ConfigChanged: configState.ConfigChanged, + RestartRequired: configState.RestartRequired, Warnings: warningViews(warnings), + } + if auditErr := server.writeAudit(request.Context(), "class_set", "", "", "", "succeeded", "", request.RemoteAddr); auditErr != nil { + result.Warnings = append(result.Warnings, WarningView{Code: "audit_write_failed", Message: auditErr.Error()}) + } + writeJSON(response, http.StatusOK, result) +} + +func (server *Server) handleRemoveClass(response http.ResponseWriter, request *http.Request) { + class, err := pathName(request.URL.Path, "/api/classes/") + if err != nil { + writeMappedError(response, err) + return + } + server.vgClassGate.Lock() + defer server.vgClassGate.Unlock() + if _, err := server.options.Classes.Remove(request.Context(), class); err != nil { + writeMappedError(response, err) + return + } + if auditErr := server.writeAudit(request.Context(), "class_remove", "", "", "", "succeeded", "", request.RemoteAddr); auditErr != nil { + server.options.Logger.Warn("admin UI audit write failed", "action", "class_remove", "error", auditErr) + } + response.WriteHeader(http.StatusNoContent) +} + +func (server *Server) buildVGStatusFromCapacity(name string, capacity lvm.Capacity, configured bool, errMessage string) VGStatus { + view := VGStatus{ + Name: name, UUID: capacity.VGUUID, State: capacity.VGState, + StorageClass: server.options.Classes.ClassName(name), Configured: configured, + TotalBytes: capacity.TotalBytes, FreeBytes: capacity.FreeBytes, + AllocatableBytes: capacity.AllocatableBytes, ExtentSizeBytes: capacity.ExtentSizeBytes, + MaxVolumeBytes: capacity.MaxVolumeBytes, ObservedAt: formatTime(capacity.ObservedAt), + Error: errMessage, + } + if view.State == "" { + view.State = "active" + } + return view +} + +func (server *Server) configuredVG(name string) bool { + for _, configured := range server.effectiveVGs() { + if configured == name { + return true + } + } + return false +} + +func pathName(fullPath, prefix string) (string, error) { + if !strings.HasPrefix(fullPath, prefix) { + return "", fmt.Errorf("%w: invalid path", errBadRequest) + } + raw := strings.TrimPrefix(fullPath, prefix) + if raw == "" || strings.Contains(raw, "/") { + return "", fmt.Errorf("%w: name is required", errBadRequest) + } + name, err := url.PathUnescape(raw) + if err != nil || name == "" { + return "", fmt.Errorf("%w: invalid name", errBadRequest) + } + return name, nil +} + +func decodeJSON(body []byte, target any) error { + decoder := json.NewDecoder(strings.NewReader(string(body))) + decoder.DisallowUnknownFields() + return decoder.Decode(target) +} + +func warningViews(warnings []string) []WarningView { + result := make([]WarningView, 0, len(warnings)) + for _, warning := range warnings { + message := warning + switch warning { + case "durability_uncertain": + message = "the file was replaced but the directory sync failed" + case "audit_write_failed": + message = "the mutation succeeded but the audit row could not be written" + } + result = append(result, WarningView{Code: warning, Message: message}) + } + return result +} diff --git a/internal/agent/admin_service.go b/internal/agent/admin_service.go index 6b053e6..84f1c45 100644 --- a/internal/agent/admin_service.go +++ b/internal/agent/admin_service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "regexp" + "sync" "time" "github.com/google/uuid" @@ -27,12 +28,15 @@ type DriftRetrier interface { type AdminService struct { koujav1.UnimplementedAdminServiceServer - store *state.Store - lvm lvm.Backend - exporters map[string]target.Exporter - creds credential.Manager - retrier DriftRetrier - classes map[string]string + store *state.Store + lvm lvm.Backend + exporters map[string]target.Exporter + creds credential.Manager + retrier DriftRetrier + classes map[string]string + classRegistry ClassRegistry + vgs VGService + vgClassGate *sync.Mutex } type AdminOption func(*AdminService) @@ -61,11 +65,40 @@ func WithStorageClasses(classes map[string]string) AdminOption { } } +func WithClassRegistry(registry ClassRegistry) AdminOption { + return func(service *AdminService) { + service.classRegistry = registry + } +} + +func WithVGService(vgs VGService) AdminOption { + return func(service *AdminService) { + service.vgs = vgs + } +} + +func WithVGClassGate(gate *sync.Mutex) AdminOption { + return func(service *AdminService) { + if gate != nil { + service.vgClassGate = gate + } + } +} + func NewAdminService(store *state.Store, lvmBackend lvm.Backend, options ...AdminOption) *AdminService { - service := &AdminService{store: store, lvm: lvmBackend, exporters: make(map[string]target.Exporter)} + service := &AdminService{ + store: store, lvm: lvmBackend, exporters: make(map[string]target.Exporter), + vgClassGate: &sync.Mutex{}, + } for _, option := range options { option(service) } + if service.vgs == nil { + service.vgs = NewVGManager(store, lvmBackend, service.classRegistry) + } + if service.vgClassGate == nil { + service.vgClassGate = &sync.Mutex{} + } return service } @@ -76,24 +109,12 @@ func (service *AdminService) RegisterVG(ctx context.Context, request *koujav1.Re if !vgNamePattern.MatchString(request.GetVgName()) { return nil, status.Error(codes.InvalidArgument, "invalid vg_name") } - if err := service.lvm.Eligibility(ctx, request.GetVgName()); err != nil { - return nil, statusError(err) + if service.vgs == nil { + return nil, status.Error(codes.FailedPrecondition, "volume group service is not configured") } - capacity, err := service.lvm.Capacity(ctx, request.GetVgName()) + capacity, err := service.vgs.Register(ctx, request.GetVgName(), request.GetVgUuid(), true) if err != nil { - return nil, statusError(err) - } - if request.GetVgUuid() != "" && request.GetVgUuid() != capacity.VGUUID { - return nil, status.Error(codes.FailedPrecondition, "vg_uuid does not match the observed volume group") - } - now := time.Now().UTC() - if err := service.store.Transact(ctx, func(tx state.Tx) error { - return tx.PutVG(state.VG{ - Name: request.GetVgName(), UUID: capacity.VGUUID, State: "active", - RegisteredAt: now, UpdatedAt: now, - }) - }); err != nil { - return nil, statusError(err) + return nil, vgStatusError(err) } return &koujav1.RegisterVGResponse{ Capacity: service.capacityProto(capacity), RequestId: requestID(ctx, request.GetRequestId()), @@ -107,13 +128,13 @@ func (service *AdminService) DeregisterVG(ctx context.Context, request *koujav1. if request.GetVgName() == "" { return nil, status.Error(codes.InvalidArgument, "vg_name is required") } - if err := service.store.Transact(ctx, func(tx state.Tx) error { - return tx.DeleteVG(request.GetVgName()) - }); err != nil { - if errors.Is(err, state.ErrConflict) { - return nil, status.Error(codes.FailedPrecondition, "volume group still has volumes") - } - return nil, statusError(err) + if service.vgs == nil { + return nil, status.Error(codes.FailedPrecondition, "volume group service is not configured") + } + service.vgClassGate.Lock() + defer service.vgClassGate.Unlock() + if err := service.vgs.Deregister(ctx, request.GetVgName()); err != nil { + return nil, vgStatusError(err) } return &koujav1.DeregisterVGResponse{RequestId: requestID(ctx, request.GetRequestId())}, nil } @@ -123,7 +144,13 @@ func (service *AdminService) ListVGs(ctx context.Context, _ *koujav1.ListVGsRequ return nil, statusError(err) } var vgs []state.VG - if err := service.store.Transact(ctx, func(tx state.Tx) error { + if service.vgs != nil { + values, err := service.vgs.List(ctx) + if err != nil { + return nil, vgStatusError(err) + } + vgs = values + } else if err := service.store.Transact(ctx, func(tx state.Tx) error { values, err := tx.ListVGs() vgs = values return err @@ -136,7 +163,7 @@ func (service *AdminService) ListVGs(ctx context.Context, _ *koujav1.ListVGsRequ if err != nil { response.Vgs = append(response.Vgs, &koujav1.Capacity{ VgName: vg.Name, VgUuid: vg.UUID, VgState: "unavailable", - StorageClass: storageClassForVG(service.classes, vg.Name), + StorageClass: service.storageClass(vg.Name), }) continue } @@ -145,6 +172,27 @@ func (service *AdminService) ListVGs(ctx context.Context, _ *koujav1.ListVGsRequ return response, nil } +func (service *AdminService) storageClass(vg string) string { + if service.classRegistry != nil { + return service.classRegistry.ClassName(vg) + } + return storageClassForVG(service.classes, vg) +} + +func vgStatusError(err error) error { + switch { + case errors.Is(err, ErrVGUUIDMismatch), errors.Is(err, ErrVGUnavailable), + errors.Is(err, ErrVGInUse), errors.Is(err, ErrVGClassMapped): + return status.Error(codes.FailedPrecondition, err.Error()) + case errors.Is(err, ErrVGNotFound): + return status.Error(codes.NotFound, err.Error()) + case errors.Is(err, ErrVGConflict): + return status.Error(codes.AlreadyExists, err.Error()) + default: + return statusError(err) + } +} + func (service *AdminService) GrantCredential(ctx context.Context, request *koujav1.GrantCredentialRequest) (*koujav1.GrantCredentialResponse, error) { identity, err := requireIdentity(ctx, auth.RoleController, auth.RoleAdmin) if err != nil { @@ -485,13 +533,13 @@ func (service *AdminService) updateDrift(ctx context.Context, volume state.Volum func (service *AdminService) volumeProto(volume state.Volume) *koujav1.Volume { proto := toProtoVolume(volume) - proto.StorageClass = storageClassForVG(service.classes, volume.VGName) + proto.StorageClass = service.storageClass(volume.VGName) return proto } func (service *AdminService) capacityProto(capacity lvm.Capacity) *koujav1.Capacity { proto := toProtoCapacity(capacity) - proto.StorageClass = storageClassForVG(service.classes, capacity.VGName) + proto.StorageClass = service.storageClass(capacity.VGName) return proto } diff --git a/internal/agent/class_registry.go b/internal/agent/class_registry.go new file mode 100644 index 0000000..c60ad9f --- /dev/null +++ b/internal/agent/class_registry.go @@ -0,0 +1,102 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/voidstarit/kouja/internal/config" +) + +var ErrClassConflict = config.ErrClassConflict + +// ClassRegistry is the live storage-class map used by the volume and admin +// services. Writes persist through the configuration file and then swap the +// in-memory map under one lock. +type ClassRegistry interface { + Snapshot() map[string]string + ClassName(vg string) string + Resolve(class, vg string) (resolvedClass, resolvedVG string, err error) + Set(ctx context.Context, class, vg string) (warnings []string, err error) + Remove(ctx context.Context, class string) (warnings []string, err error) +} + +type ClassMap struct { + mu sync.RWMutex + classes map[string]string + configPath string +} + +var _ ClassRegistry = (*ClassMap)(nil) + +func NewClassMap(classes map[string]string, configPath string) *ClassMap { + copyOfClasses := make(map[string]string, len(classes)) + for class, vg := range classes { + copyOfClasses[class] = vg + } + return &ClassMap{classes: copyOfClasses, configPath: configPath} +} + +func (m *ClassMap) Snapshot() map[string]string { + m.mu.RLock() + defer m.mu.RUnlock() + result := make(map[string]string, len(m.classes)) + for class, vg := range m.classes { + result[class] = vg + } + return result +} + +func (m *ClassMap) ClassName(vg string) string { + m.mu.RLock() + defer m.mu.RUnlock() + return config.StorageClassForVG(m.classes, vg) +} + +func (m *ClassMap) Resolve(class, vg string) (string, string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return resolveStorageClass(m.classes, class, vg) +} + +func (m *ClassMap) Set(_ context.Context, class, vg string) ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.configPath == "" { + return nil, fmt.Errorf("agent: class registry has no configuration path") + } + for otherClass, otherVG := range m.classes { + if otherClass != class && otherVG == vg { + return nil, fmt.Errorf("%w: class %q already maps to VG %q", ErrClassConflict, otherClass, vg) + } + } + warnings, err := config.SetClass(m.configPath, class, vg) + if err != nil { + return nil, err + } + m.classes[class] = vg + return warnings, nil +} + +func (m *ClassMap) Remove(_ context.Context, class string) ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.configPath == "" { + return nil, fmt.Errorf("agent: class registry has no configuration path") + } + if _, ok := m.classes[class]; !ok { + return nil, config.ErrClassNotFound + } + warnings, err := config.RemoveClass(m.configPath, class) + if err != nil { + return nil, err + } + delete(m.classes, class) + return warnings, nil +} + +// IsClassNotFound reports whether err is the missing-class sentinel. +func IsClassNotFound(err error) bool { + return errors.Is(err, config.ErrClassNotFound) +} diff --git a/internal/agent/vg_class_test.go b/internal/agent/vg_class_test.go new file mode 100644 index 0000000..f2e95ec --- /dev/null +++ b/internal/agent/vg_class_test.go @@ -0,0 +1,162 @@ +package agent + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/voidstarit/kouja/internal/config" + "github.com/voidstarit/kouja/internal/lvm" + "github.com/voidstarit/kouja/internal/state" + "gopkg.in/yaml.v3" +) + +type testClassRegistry struct { + mu sync.RWMutex + classes map[string]string +} + +func (registry *testClassRegistry) Snapshot() map[string]string { + registry.mu.RLock() + defer registry.mu.RUnlock() + result := make(map[string]string, len(registry.classes)) + for class, vg := range registry.classes { + result[class] = vg + } + return result +} +func (registry *testClassRegistry) ClassName(vg string) string { + registry.mu.RLock() + defer registry.mu.RUnlock() + for class, mapped := range registry.classes { + if mapped == vg { + return class + } + } + return vg +} +func (registry *testClassRegistry) Resolve(class, vg string) (string, string, error) { + return resolveStorageClass(registry.Snapshot(), class, vg) +} +func (registry *testClassRegistry) Set(context.Context, string, string) ([]string, error) { + return nil, nil +} +func (registry *testClassRegistry) Remove(context.Context, string) ([]string, error) { + return nil, nil +} + +func TestVGManagerRegistrationSemantics(t *testing.T) { + store := newAgentStore(t) + backend := &fakeLVM{capacity: lvmCapacityFor("vg1", "uuid-1")} + manager := NewVGManager(store, backend, nil) + ctx := context.Background() + if _, err := manager.Register(ctx, "vg1", "", false); err != nil { + t.Fatal(err) + } + if _, err := manager.Register(ctx, "vg1", "uuid-1", false); err != nil { + t.Fatalf("idempotent register: %v", err) + } + if _, err := manager.Register(ctx, "vg1", "wrong", false); !errors.Is(err, ErrVGUUIDMismatch) { + t.Fatalf("mismatch = %v", err) + } + backend.capacity.VGUUID = "uuid-2" + if _, err := manager.Register(ctx, "vg1", "", false); !errors.Is(err, ErrVGConflict) { + t.Fatalf("conflict = %v", err) + } + if _, err := manager.Register(ctx, "vg1", "uuid-2", true); err != nil { + t.Fatalf("replace = %v", err) + } + if _, err := manager.Inspect(ctx, "vg1"); err != nil { + t.Fatalf("inspect = %v", err) + } + if err := manager.Deregister(ctx, "vg1"); err != nil { + t.Fatalf("deregister = %v", err) + } +} + +func TestVGManagerClassGuard(t *testing.T) { + store := newAgentStore(t) + now := time.Now().UTC() + if err := store.Transact(context.Background(), func(tx state.Tx) error { + return tx.PutVG(state.VG{Name: "vg1", UUID: "uuid-1", State: "active", RegisteredAt: now, UpdatedAt: now}) + }); err != nil { + t.Fatal(err) + } + manager := NewVGManager(store, &fakeLVM{capacity: lvmCapacityFor("vg1", "uuid-1")}, &testClassRegistry{classes: map[string]string{"ssd": "vg1"}}) + if err := manager.Deregister(context.Background(), "vg1"); !errors.Is(err, ErrVGClassMapped) { + t.Fatalf("class guard = %v", err) + } +} + +func TestClassMapPersistsAndRejectsConflicts(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + cfg := config.Default() + cfg.AdminUI.Enabled = false + cfg.Storage.VGs = []string{"vg1"} + cfg.Storage.Classes = map[string]string{} + cfg.Targets.ISCSI.Portals = []config.Portal{{Address: "127.0.0.1", Port: 3260}} + raw, err := yaml.Marshal(&cfg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + classes := NewClassMap(map[string]string{}, path) + if _, err := classes.Set(context.Background(), "ssd", "vg1"); err != nil { + t.Fatal(err) + } + if classes.ClassName("vg1") != "ssd" { + t.Fatalf("class name = %q", classes.ClassName("vg1")) + } + if _, err := classes.Set(context.Background(), "other", "vg1"); !errors.Is(err, ErrClassConflict) { + t.Fatalf("conflict = %v", err) + } + if _, err := classes.Remove(context.Background(), "missing"); !errors.Is(err, config.ErrClassNotFound) { + t.Fatalf("missing = %v", err) + } + if _, err := classes.Remove(context.Background(), "ssd"); err != nil { + t.Fatal(err) + } + if classes.ClassName("vg1") != "vg1" { + t.Fatalf("class name after remove = %q", classes.ClassName("vg1")) + } +} + +func lvmCapacityFor(name, uuid string) lvm.Capacity { + return lvm.Capacity{ + VGName: name, VGUUID: uuid, VGState: "active", + TotalBytes: 1 << 30, FreeBytes: 1 << 30, AllocatableBytes: 1 << 30, + ExtentSizeBytes: 4 << 20, MaxVolumeBytes: 1 << 30, + ObservedAt: time.Now().UTC(), + } +} + +func TestVGManagerUnavailableAndNotFound(t *testing.T) { + store := newAgentStore(t) + manager := NewVGManager(store, &fakeLVM{eligibilityErr: errors.New("not eligible")}, nil) + if _, err := manager.Register(context.Background(), "vg1", "", false); !errors.Is(err, ErrVGUnavailable) { + t.Fatalf("ineligible = %v", err) + } + if _, err := manager.Inspect(context.Background(), "bad name"); !errors.Is(err, ErrVGUnavailable) { + t.Fatalf("bad name = %v", err) + } + if err := manager.Deregister(context.Background(), "missing"); !errors.Is(err, ErrVGNotFound) { + t.Fatalf("missing deregister = %v", err) + } + if _, err := manager.List(context.Background()); err != nil { + t.Fatal(err) + } + classes := NewClassMap(map[string]string{"ssd": "vg1"}, "") + if _, err := classes.Set(context.Background(), "hdd", "vg2"); err == nil { + t.Fatal("class set without config path succeeded") + } + if _, err := classes.Remove(context.Background(), "missing"); err == nil { + t.Fatal("remove without config path succeeded") + } +} diff --git a/internal/agent/vg_service.go b/internal/agent/vg_service.go new file mode 100644 index 0000000..f62718e --- /dev/null +++ b/internal/agent/vg_service.go @@ -0,0 +1,127 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/voidstarit/kouja/internal/lvm" + "github.com/voidstarit/kouja/internal/state" +) + +var ( + ErrVGInUse = errors.New("agent: volume group is in use") + ErrVGUnavailable = errors.New("agent: volume group is unavailable") + ErrVGConflict = errors.New("agent: volume group already exists with a different UUID") + ErrVGUUIDMismatch = errors.New("agent: volume group UUID does not match") + ErrVGNotFound = errors.New("agent: volume group not found") + ErrVGClassMapped = errors.New("agent: volume group is referenced by a storage class") +) + +type VGService interface { + Register(ctx context.Context, name, expectedUUID string, replace bool) (lvm.Capacity, error) + Deregister(ctx context.Context, name string) error + List(ctx context.Context) ([]state.VG, error) + Inspect(ctx context.Context, name string) (lvm.Capacity, error) +} + +type VGManager struct { + store *state.Store + lvm lvm.Backend + classes ClassRegistry +} + +var _ VGService = (*VGManager)(nil) + +func NewVGManager(store *state.Store, lvmBackend lvm.Backend, classes ClassRegistry) *VGManager { + return &VGManager{store: store, lvm: lvmBackend, classes: classes} +} + +func (manager *VGManager) Inspect(ctx context.Context, name string) (lvm.Capacity, error) { + if manager == nil || manager.lvm == nil { + return lvm.Capacity{}, fmt.Errorf("%w: LVM backend is not configured", ErrVGUnavailable) + } + if !vgNamePattern.MatchString(name) { + return lvm.Capacity{}, fmt.Errorf("%w: invalid VG name %q", ErrVGUnavailable, name) + } + if err := manager.lvm.Eligibility(ctx, name); err != nil { + return lvm.Capacity{}, fmt.Errorf("%w: %v", ErrVGUnavailable, err) + } + capacity, err := manager.lvm.Capacity(ctx, name) + if err != nil { + return lvm.Capacity{}, fmt.Errorf("%w: %v", ErrVGUnavailable, err) + } + return capacity, nil +} + +func (manager *VGManager) Register(ctx context.Context, name, expectedUUID string, replace bool) (lvm.Capacity, error) { + capacity, err := manager.Inspect(ctx, name) + if err != nil { + return lvm.Capacity{}, err + } + if expectedUUID != "" && expectedUUID != capacity.VGUUID { + return lvm.Capacity{}, fmt.Errorf("%w: expected %q, observed %q", ErrVGUUIDMismatch, expectedUUID, capacity.VGUUID) + } + now := time.Now().UTC() + err = manager.store.Transact(ctx, func(tx state.Tx) error { + existing, getErr := tx.GetVG(name) + switch { + case errors.Is(getErr, state.ErrNotFound): + return tx.PutVG(state.VG{ + Name: name, UUID: capacity.VGUUID, State: "active", + RegisteredAt: now, UpdatedAt: now, + }) + case getErr != nil: + return getErr + case existing.UUID == capacity.VGUUID: + existing.State = "active" + existing.UpdatedAt = now + return tx.PutVG(existing) + case !replace: + return fmt.Errorf("%w: existing UUID %q, observed %q", ErrVGConflict, existing.UUID, capacity.VGUUID) + default: + existing.UUID = capacity.VGUUID + existing.State = "active" + existing.UpdatedAt = now + return tx.PutVG(existing) + } + }) + if err != nil { + return lvm.Capacity{}, err + } + return capacity, nil +} + +func (manager *VGManager) Deregister(ctx context.Context, name string) error { + if name == "" { + return fmt.Errorf("%w: VG name is required", ErrVGNotFound) + } + if manager.classes != nil { + for class, vg := range manager.classes.Snapshot() { + if vg == name { + return fmt.Errorf("%w: class %q", ErrVGClassMapped, class) + } + } + } + err := manager.store.Transact(ctx, func(tx state.Tx) error { + return tx.DeleteVG(name) + }) + if errors.Is(err, state.ErrConflict) { + return fmt.Errorf("%w: %v", ErrVGInUse, err) + } + if errors.Is(err, state.ErrNotFound) { + return ErrVGNotFound + } + return err +} + +func (manager *VGManager) List(ctx context.Context) ([]state.VG, error) { + var vgs []state.VG + err := manager.store.Transact(ctx, func(tx state.Tx) error { + values, err := tx.ListVGs() + vgs = values + return err + }) + return vgs, err +} diff --git a/internal/agent/volume_service.go b/internal/agent/volume_service.go index b208fcd..a839f6e 100644 --- a/internal/agent/volume_service.go +++ b/internal/agent/volume_service.go @@ -40,7 +40,11 @@ type ServiceOptions struct { Ready func() bool Admission *Admission // StorageClasses maps a stable class name to an LVM volume group. + // Deprecated: use Classes for live mappings. It remains as a fallback for + // tests and callers that only need a static map. StorageClasses map[string]string + // Classes is the live storage-class registry shared with the admin UI. + Classes ClassRegistry } type VolumeService struct { @@ -56,6 +60,20 @@ func NewVolumeService(store *state.Store, lvmBackend lvm.Backend, runner Operati return &VolumeService{store: store, lvm: lvmBackend, runner: runner, creds: creds, options: options} } +func (service *VolumeService) resolveClass(class, vg string) (string, string, error) { + if service.options.Classes != nil { + return service.options.Classes.Resolve(class, vg) + } + return resolveStorageClass(service.options.StorageClasses, class, vg) +} + +func (service *VolumeService) storageClass(vg string) string { + if service.options.Classes != nil { + return service.options.Classes.ClassName(vg) + } + return storageClassForVG(service.options.StorageClasses, vg) +} + func (service *VolumeService) CreateVolume(ctx context.Context, request *koujav1.CreateVolumeRequest) (*koujav1.CreateVolumeResponse, error) { identity, err := requireIdentity(ctx, auth.RoleCaller, auth.RoleController, auth.RoleAdmin) if err != nil { @@ -72,7 +90,7 @@ func (service *VolumeService) CreateVolume(ctx context.Context, request *koujav1 if request.GetVolumeId() == "" || request.GetSizeBytes() <= 0 { return nil, status.Error(codes.InvalidArgument, "volume_id and positive size_bytes are required") } - _, vg, resolveErr := resolveStorageClass(service.options.StorageClasses, request.GetStorageClass(), request.GetVg()) + _, vg, resolveErr := service.resolveClass(request.GetStorageClass(), request.GetVg()) if resolveErr != nil { return nil, status.Error(codes.InvalidArgument, resolveErr.Error()) } @@ -448,7 +466,7 @@ func (service *VolumeService) GetCapacity(ctx context.Context, _ *koujav1.GetCap if err != nil { response.Vgs = append(response.Vgs, &koujav1.Capacity{ VgName: vg.Name, VgUuid: vg.UUID, VgState: "unavailable", - StorageClass: storageClassForVG(service.options.StorageClasses, vg.Name), + StorageClass: service.storageClass(vg.Name), }) continue } @@ -635,13 +653,13 @@ func protocolProto(protocol credential.Protocol) koujav1.Protocol { func (service *VolumeService) volumeProto(volume state.Volume) *koujav1.Volume { proto := toProtoVolume(volume) - proto.StorageClass = storageClassForVG(service.options.StorageClasses, volume.VGName) + proto.StorageClass = service.storageClass(volume.VGName) return proto } func (service *VolumeService) capacityProto(capacity lvm.Capacity) *koujav1.Capacity { proto := toProtoCapacity(capacity) - proto.StorageClass = storageClassForVG(service.options.StorageClasses, capacity.VGName) + proto.StorageClass = service.storageClass(capacity.VGName) return proto } diff --git a/internal/agent/volume_service_test.go b/internal/agent/volume_service_test.go index 618f359..aec0101 100644 --- a/internal/agent/volume_service_test.go +++ b/internal/agent/volume_service_test.go @@ -34,8 +34,9 @@ func (runner *fakeRunner) Run(_ context.Context, request operations.Request) (op } type fakeLVM struct { - capacity lvm.Capacity - capacityErr error + capacity lvm.Capacity + capacityErr error + eligibilityErr error } func (backend *fakeLVM) Create(context.Context, string, string, int64, map[string]string) (lvm.LV, error) { @@ -62,7 +63,7 @@ func (backend *fakeLVM) SetOwnerTag(context.Context, string, string, string) err func (backend *fakeLVM) Capacity(context.Context, string) (lvm.Capacity, error) { return backend.capacity, backend.capacityErr } -func (backend *fakeLVM) Eligibility(context.Context, string) error { return nil } +func (backend *fakeLVM) Eligibility(context.Context, string) error { return backend.eligibilityErr } type fakeCredentialManager struct { record credential.Credential diff --git a/internal/config/adminui_test.go b/internal/config/adminui_test.go new file mode 100644 index 0000000..37cb2e8 --- /dev/null +++ b/internal/config/adminui_test.go @@ -0,0 +1,164 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestAdminUIConfigAndLoadWithData(t *testing.T) { + cfg, err := Parse([]byte(validConfig)) + if err != nil { + t.Fatal(err) + } + if cfg.AdminUI.Enabled || cfg.AdminUI.Address != "127.0.0.1:9444" || + cfg.AdminUI.ClientCertDir != "/etc/kouja/tls/clients" { + t.Fatalf("admin defaults = %#v", cfg.AdminUI) + } + path := filepath.Join(t.TempDir(), "config.yaml") + raw := []byte(validConfig + "\nadmin_ui:\n enabled: true\n address: 127.0.0.1:9555\n") + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + loaded, data, err := LoadWithData(path) + if err != nil { + t.Fatal(err) + } + if !loaded.AdminUI.Enabled || loaded.AdminUI.Address != "127.0.0.1:9555" { + t.Fatalf("admin config = %#v", loaded.AdminUI) + } + if string(data) != string(raw) { + t.Fatal("LoadWithData did not preserve bytes") + } + for _, address := range []string{"0.0.0.0:9444", "127.0.0.2:9444", "localhost:9444", "127.0.0.1:0", "127.0.0.1:70000"} { + _, err := Parse([]byte(validConfig + "\nadmin_ui:\n enabled: false\n address: " + address + "\n")) + if !errors.Is(err, ErrInvalid) { + t.Fatalf("address %q error = %v, want ErrInvalid", address, err) + } + } + _, err = Parse([]byte(validConfig + "\nadmin_ui:\n enabled: false\n client_cert_dir: relative\n")) + if !errors.Is(err, ErrInvalid) { + t.Fatalf("relative client cert dir = %v, want ErrInvalid", err) + } +} + +func TestStorageClassUpdater(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(validConfig), 0o600); err != nil { + t.Fatal(err) + } + warnings, err := SetClass(path, "ssd", "vg1") + if err != nil || len(warnings) != 0 { + t.Fatalf("SetClass = %v, %v", warnings, err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Storage.Classes["ssd"] != "vg1" { + t.Fatalf("classes = %#v", cfg.Storage.Classes) + } + if _, err := os.Stat(path + ".bak"); err != nil { + t.Fatalf("backup: %v", err) + } + if _, err := SetClass(path, "other", "vg1"); !errors.Is(err, ErrClassConflict) { + t.Fatalf("duplicate VG error = %v", err) + } + if _, err := SetClass(path, "ssd", "vg2"); err != nil { + t.Fatalf("replace class: %v", err) + } + if _, err := RemoveClass(path, "missing"); !errors.Is(err, ErrClassNotFound) { + t.Fatalf("missing remove = %v", err) + } + if _, err := RemoveClass(path, "ssd"); err != nil { + t.Fatalf("remove class: %v", err) + } + cfg, err = Load(path) + if err != nil { + t.Fatal(err) + } + if _, ok := cfg.Storage.Classes["ssd"]; ok { + t.Fatal("class was not removed") + } +} + +func TestStorageClassUpdaterRejectsUnsafePath(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(target, []byte(validConfig), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link.yaml") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := SetClass(link, "ssd", "vg1"); !errors.Is(err, ErrUnsafePath) { + t.Fatalf("symlink error = %v", err) + } +} + +func TestStorageClassUpdaterErrorsAndMissingStorage(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(validConfig), 0o600); err != nil { + t.Fatal(err) + } + lock, err := acquireConfigLock(path, time.Second) + if err != nil { + t.Fatal(err) + } + if _, err := SetClass(path, "ssd", "vg1"); !errors.Is(err, ErrLocked) { + t.Fatalf("locked SetClass = %v", err) + } + releaseConfigLock(lock) + if _, err := SetClass(filepath.Join(dir, "missing.yaml"), "ssd", "vg1"); err == nil { + t.Fatal("missing file SetClass succeeded") + } + if _, err := SetClass(path, "bad class", "vg1"); !errors.Is(err, ErrInvalid) { + t.Fatalf("invalid class = %v", err) + } + noStorage := []byte(` +host_id_file: /var/lib/kouja/host-id +data_dir: /var/lib/kouja +secret_key_file: /var/lib/kouja/secret.key +emergency_log: /var/lib/kouja/emergency.log +listen: + address: 127.0.0.1:9443 + tls: {cert_file: /a, key_file: /b, client_ca_file: /c} +targets: + iscsi: + portals: [{address: 127.0.0.1, port: 3260}] +`) + noStoragePath := filepath.Join(dir, "no-storage.yaml") + if err := os.WriteFile(noStoragePath, noStorage, 0o600); err != nil { + t.Fatal(err) + } + if _, err := SetClass(noStoragePath, "ssd", "vg1"); err != nil { + t.Fatalf("create storage section: %v", err) + } + if _, err := RemoveClass(noStoragePath, "missing"); !errors.Is(err, ErrClassNotFound) { + t.Fatalf("missing class = %v", err) + } +} + +func TestLoadWithDataErrors(t *testing.T) { + if _, _, err := LoadWithData(""); !errors.Is(err, ErrInvalid) { + t.Fatalf("empty = %v", err) + } + if _, _, err := LoadWithData(filepath.Join(t.TempDir(), "missing")); err == nil { + t.Fatal("missing load succeeded") + } +} + +func TestExampleConfigParses(t *testing.T) { + path := filepath.Join("..", "..", "deploy", "config.example.yaml") + raw, err := os.ReadFile(path) //nolint:gosec // repository example file + if err != nil { + t.Fatal(err) + } + if _, err := Parse(raw); err != nil { + t.Fatalf("example config: %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 6198bb2..0737a88 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "gopkg.in/yaml.v3" @@ -22,6 +23,7 @@ type Config struct { SecretKeyFile string `yaml:"secret_key_file"` EmergencyLog string `yaml:"emergency_log"` Listen ListenConfig `yaml:"listen"` + AdminUI AdminUIConfig `yaml:"admin_ui"` Storage StorageConfig `yaml:"storage"` Targets TargetsConfig `yaml:"targets"` Operations OperationsConfig `yaml:"operations"` @@ -29,6 +31,13 @@ type Config struct { State StateConfig `yaml:"state"` } +// AdminUIConfig configures the optional loopback-only local administration UI. +type AdminUIConfig struct { + Enabled bool `yaml:"enabled"` + Address string `yaml:"address"` + ClientCertDir string `yaml:"client_cert_dir"` +} + type ListenConfig struct { Address string `yaml:"address"` TLS TLSConfig `yaml:"tls"` @@ -146,6 +155,10 @@ func Default() Config { MinVersion: "TLS1.3", }, }, + AdminUI: AdminUIConfig{ + Enabled: false, + Address: "127.0.0.1:9444", + }, Storage: StorageConfig{ HeadroomPercent: 5, HeadroomMinBytes: 1 << 30, @@ -195,14 +208,25 @@ func Default() Config { } func Load(path string) (*Config, error) { + cfg, _, err := LoadWithData(path) + return cfg, err +} + +// LoadWithData loads and parses the configuration and returns the exact bytes +// that were read. The raw bytes are used for the admin UI recovery file. +func LoadWithData(path string) (*Config, []byte, error) { if strings.TrimSpace(path) == "" { - return nil, fmt.Errorf("%w: empty configuration path", ErrInvalid) + return nil, nil, fmt.Errorf("%w: empty configuration path", ErrInvalid) } data, err := os.ReadFile(path) //nolint:gosec // path is the operator-supplied configuration file if err != nil { - return nil, fmt.Errorf("config: read %q: %w", path, err) + return nil, nil, fmt.Errorf("config: read %q: %w", path, err) } - return Parse(data) + cfg, err := Parse(data) + if err != nil { + return nil, nil, err + } + return cfg, data, nil } func Parse(data []byte) (*Config, error) { @@ -221,6 +245,12 @@ func Parse(data []byte) (*Config, error) { func (cfg *Config) applyDefaults() { defaults := Default() + if cfg.AdminUI.Address == "" { + cfg.AdminUI.Address = defaults.AdminUI.Address + } + if cfg.AdminUI.ClientCertDir == "" { + cfg.AdminUI.ClientCertDir = filepath.Join(filepath.Dir(cfg.Listen.TLS.CertFile), "clients") + } for _, action := range []string{"create", "export", "delete", "resize"} { if cfg.Operations.StepTimeoutSecs == nil { cfg.Operations.StepTimeoutSecs = make(map[string]int) @@ -253,6 +283,14 @@ func (cfg *Config) Validate() error { if err := validateHostPort("listen.address", cfg.Listen.Address); err != nil { return err } + if err := validateAdminUIAddress(cfg.AdminUI.Address); err != nil { + return err + } + if cfg.AdminUI.ClientCertDir != "" { + if err := requireAbsolute("admin_ui.client_cert_dir", cfg.AdminUI.ClientCertDir); err != nil { + return err + } + } if cfg.Storage.HeadroomPercent < 0 || cfg.Storage.HeadroomPercent > 100 { return fmt.Errorf("%w: storage.headroom_percent must be between 0 and 100", ErrInvalid) } @@ -346,6 +384,18 @@ func validateHostPort(kind, address string) error { return nil } +func validateAdminUIAddress(address string) error { + host, portText, err := net.SplitHostPort(address) + if err != nil || (host != "127.0.0.1" && host != "::1") { + return fmt.Errorf("%w: admin_ui.address must be a loopback IP literal:port", ErrInvalid) + } + port, err := strconv.Atoi(portText) + if err != nil || port < 1 || port > 65535 { + return fmt.Errorf("%w: admin_ui.address has invalid port", ErrInvalid) + } + return nil +} + func validateTargetPortals(kind, prefix string, portals []Portal) error { if prefix == "" { return fmt.Errorf("%w: %s prefix is required", ErrInvalid, kind) diff --git a/internal/config/storage.go b/internal/config/storage.go new file mode 100644 index 0000000..2146b0c --- /dev/null +++ b/internal/config/storage.go @@ -0,0 +1,194 @@ +package config + +import ( + "errors" + "fmt" + "os" + "time" + + "github.com/voidstarit/kouja/internal/fsutil" + "golang.org/x/sys/unix" + "gopkg.in/yaml.v3" +) + +var ( + ErrLocked = errors.New("config: configuration lock is busy") + ErrClassNotFound = errors.New("config: storage class not found") + ErrUnsafePath = errors.New("config: configuration path is not a safe regular file") + ErrClassConflict = errors.New("config: storage class maps to an already used VG") +) + +// SetClass updates storage.classes in the YAML file under the configuration +// lock, preserves the previous file as .bak, validates the result, and writes +// atomically. It returns non-fatal warning codes such as durability_uncertain. +func SetClass(path, class, vg string) ([]string, error) { + return updateClass(path, class, vg, false) +} + +// RemoveClass removes a class mapping from storage.classes. A missing class is +// reported as ErrClassNotFound. +func RemoveClass(path, class string) ([]string, error) { + return updateClass(path, class, "", true) +} + +func updateClass(path, class, vg string, remove bool) ([]string, error) { + if path == "" || class == "" || (!remove && vg == "") { + return nil, fmt.Errorf("%w: path, class, and vg are required", ErrInvalid) + } + if err := rejectUnsafePath(path); err != nil { + return nil, err + } + lock, err := acquireConfigLock(path, 2*time.Second) + if err != nil { + return nil, err + } + defer releaseConfigLock(lock) + + raw, err := os.ReadFile(path) //nolint:gosec // operator-supplied configuration path + if err != nil { + return nil, fmt.Errorf("config: read %q: %w", path, err) + } + current, err := Parse(raw) + if err != nil { + return nil, err + } + if remove { + if _, ok := current.Storage.Classes[class]; !ok { + return nil, ErrClassNotFound + } + } else { + for otherClass, otherVG := range current.Storage.Classes { + if otherClass != class && otherVG == vg { + return nil, fmt.Errorf("%w: class %q already maps to VG %q", ErrClassConflict, otherClass, vg) + } + } + if !vgNamePattern.MatchString(class) || !vgNamePattern.MatchString(vg) { + return nil, fmt.Errorf("%w: invalid class or VG name", ErrInvalid) + } + } + + var document yaml.Node + if err := yaml.Unmarshal(raw, &document); err != nil { + return nil, fmt.Errorf("config: parse YAML node: %w", err) + } + if err := mutateClassNode(&document, class, vg, remove); err != nil { + return nil, err + } + encoded, err := yaml.Marshal(&document) + if err != nil { + return nil, fmt.Errorf("config: marshal updated YAML: %w", err) + } + if _, err := Parse(encoded); err != nil { + return nil, err + } + + if err := fsutil.WriteFileAtomic(path+".bak", raw, 0o600); err != nil { + return nil, fmt.Errorf("config: write backup: %w", err) + } + if err := fsutil.WriteFileAtomic(path, encoded, 0o600); err != nil { + if errors.Is(err, fsutil.ErrDurabilityUncertain) { + return []string{"durability_uncertain"}, nil + } + return nil, err + } + return nil, nil +} + +func rejectUnsafePath(path string) error { + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("config: inspect %q: %w", path, err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return ErrUnsafePath + } + return nil +} + +func acquireConfigLock(path string, timeout time.Duration) (*os.File, error) { + lockPath := path + ".lock" + deadline := time.Now().Add(timeout) + for { + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec // derived from the configuration path + if err != nil { + return nil, fmt.Errorf("config: open lock: %w", err) + } + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err == nil { + return file, nil + } else if !errors.Is(err, unix.EWOULDBLOCK) && !errors.Is(err, unix.EAGAIN) { + _ = file.Close() + return nil, fmt.Errorf("config: lock: %w", err) + } + _ = file.Close() + if time.Now().After(deadline) { + return nil, ErrLocked + } + time.Sleep(25 * time.Millisecond) + } +} + +func releaseConfigLock(file *os.File) { + if file == nil { + return + } + _ = unix.Flock(int(file.Fd()), unix.LOCK_UN) + _ = file.Close() +} + +func mutateClassNode(document *yaml.Node, class, vg string, remove bool) error { + if document.Kind != yaml.DocumentNode || len(document.Content) == 0 { + return fmt.Errorf("%w: empty YAML document", ErrInvalid) + } + root := document.Content[0] + storage := mappingValue(root, "storage") + if storage == nil { + if remove { + return ErrClassNotFound + } + storage = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + root.Content = append(root.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "storage"}, storage) + } + classes := mappingValue(storage, "classes") + if classes == nil { + if remove { + return ErrClassNotFound + } + classes = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + storage.Content = append(storage.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "classes"}, classes) + } + if classes.Kind != yaml.MappingNode { + return fmt.Errorf("%w: storage.classes must be a mapping", ErrInvalid) + } + for index := 0; index+1 < len(classes.Content); index += 2 { + if classes.Content[index].Value != class { + continue + } + if remove { + classes.Content = append(classes.Content[:index], classes.Content[index+2:]...) + return nil + } + classes.Content[index+1] = &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: vg} + return nil + } + if remove { + return ErrClassNotFound + } + classes.Content = append(classes.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: class}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: vg}) + return nil +} + +func mappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for index := 0; index+1 < len(node.Content); index += 2 { + if node.Content[index].Value == key { + return node.Content[index+1] + } + } + return nil +} diff --git a/internal/config/storage_class.go b/internal/config/storage_class.go new file mode 100644 index 0000000..d5f4998 --- /dev/null +++ b/internal/config/storage_class.go @@ -0,0 +1,13 @@ +package config + +// StorageClassForVG returns the configured storage-class name for a volume +// group. A VG without an explicit class is its own class for compatibility +// with deployments that select storage by VG name. +func StorageClassForVG(classes map[string]string, vg string) string { + for class, mapped := range classes { + if mapped == vg { + return class + } + } + return vg +} diff --git a/internal/config/storage_class_test.go b/internal/config/storage_class_test.go index 2092e37..7b8f31a 100644 --- a/internal/config/storage_class_test.go +++ b/internal/config/storage_class_test.go @@ -1,5 +1,7 @@ package config +import "testing" + import ( "strings" @@ -38,3 +40,15 @@ var _ = Describe("storage classes", func() { gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("invalid storage.classes name"))) }) }) + +func TestStorageClassForVGFallback(t *testing.T) { + if got := StorageClassForVG(nil, "vg1"); got != "vg1" { + t.Fatalf("nil fallback = %q", got) + } + if got := StorageClassForVG(map[string]string{"ssd": "vg1"}, "vg1"); got != "ssd" { + t.Fatalf("mapped = %q", got) + } + if got := StorageClassForVG(map[string]string{"ssd": "vg1"}, "vg2"); got != "vg2" { + t.Fatalf("unmapped = %q", got) + } +} diff --git a/internal/fsutil/atomic.go b/internal/fsutil/atomic.go new file mode 100644 index 0000000..8d14a9b --- /dev/null +++ b/internal/fsutil/atomic.go @@ -0,0 +1,68 @@ +package fsutil + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +// ErrDurabilityUncertain means the file was renamed into place successfully, +// but syncing the containing directory failed. The new contents are visible; +// callers may treat this as success with a warning. +var ErrDurabilityUncertain = errors.New("fsutil: file renamed but directory sync failed") + +// WriteFileAtomic writes data to a temporary file in the destination directory, +// syncs it, renames it over path, and syncs the directory. The temporary file +// is removed on failure. The caller is responsible for validating path and for +// any file locking. +func WriteFileAtomic(path string, data []byte, mode fs.FileMode) error { + if path == "" { + return fmt.Errorf("fsutil: empty destination path") + } + dir := filepath.Dir(path) + temp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-") + if err != nil { + return fmt.Errorf("fsutil: create temporary file: %w", err) + } + tempName := temp.Name() + cleanup := func() { + _ = temp.Close() + _ = os.Remove(tempName) + } + fail := func(operation string, err error) error { + cleanup() + return fmt.Errorf("fsutil: %s: %w", operation, err) + } + if err := temp.Chmod(mode); err != nil { + return fail("chmod temporary file", err) + } + if _, err := temp.Write(data); err != nil { + return fail("write temporary file", err) + } + if err := temp.Sync(); err != nil { + return fail("sync temporary file", err) + } + if err := temp.Close(); err != nil { + _ = os.Remove(tempName) + return fmt.Errorf("fsutil: close temporary file: %w", err) + } + if err := os.Rename(tempName, path); err != nil { + _ = os.Remove(tempName) + return fmt.Errorf("fsutil: rename temporary file: %w", err) + } + directory, err := os.Open(dir) //nolint:gosec // destination directory is validated by the caller + if err != nil { + return fmt.Errorf("%w: open directory: %v", ErrDurabilityUncertain, err) + } + syncErr := directory.Sync() + closeErr := directory.Close() + if syncErr != nil { + return fmt.Errorf("%w: %v", ErrDurabilityUncertain, syncErr) + } + if closeErr != nil { + return fmt.Errorf("%w: close directory: %v", ErrDurabilityUncertain, closeErr) + } + return nil +} diff --git a/internal/fsutil/atomic_test.go b/internal/fsutil/atomic_test.go new file mode 100644 index 0000000..2d5f118 --- /dev/null +++ b/internal/fsutil/atomic_test.go @@ -0,0 +1,66 @@ +package fsutil + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteFileAtomicWritesAndReplaces(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := WriteFileAtomic(path, []byte("first\n"), 0o600); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode = %o", info.Mode().Perm()) + } + if err := WriteFileAtomic(path, []byte("second\n"), 0o600); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) //nolint:gosec // temporary test path + if err != nil { + t.Fatal(err) + } + if string(data) != "second\n" { + t.Fatalf("data = %q", data) + } +} + +func TestWriteFileAtomicRejectsEmptyPathAndRenamesDirectory(t *testing.T) { + if err := WriteFileAtomic("", []byte("x"), 0o600); err == nil || !strings.Contains(err.Error(), "empty") { + t.Fatalf("empty path error = %v", err) + } + dir := t.TempDir() + target := filepath.Join(dir, "target") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "child"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := WriteFileAtomic(target, []byte("x"), 0o600); err == nil { + t.Fatal("rename over a non-empty directory succeeded") + } +} + +func TestWriteFileAtomicCreateTempFailure(t *testing.T) { + dir := t.TempDir() + if err := os.Chmod(dir, 0o500); err != nil { //nolint:gosec // deliberate permission test + t.Fatal(err) + } + defer func() { _ = os.Chmod(dir, 0o700) }() //nolint:gosec // restore test directory + if err := WriteFileAtomic(filepath.Join(dir, "file"), []byte("x"), 0o600); err == nil { + t.Fatal("write into read-only directory succeeded") + } +} + +func TestWriteFileAtomicMissingParent(t *testing.T) { + if err := WriteFileAtomic(filepath.Join(t.TempDir(), "missing", "file"), []byte("x"), 0o600); err == nil { + t.Fatal("write into missing parent succeeded") + } +} diff --git a/internal/operations/disable_test.go b/internal/operations/disable_test.go new file mode 100644 index 0000000..bd1a552 --- /dev/null +++ b/internal/operations/disable_test.go @@ -0,0 +1,90 @@ +package operations + +import ( + "context" + "testing" + + "github.com/voidstarit/kouja/internal/state" + "github.com/voidstarit/kouja/internal/target" +) + +func TestEnableTargetRecreatesExportAndCredential(t *testing.T) { + manager, _, exporter, _, store := newTestManager(t) + create := createRequest(t, "vol1", "resource-1", 64<<20, true) + if _, err := manager.Run(context.Background(), create); err != nil { + t.Fatalf("create: %v", err) + } + before := activeCredential(t, store, create.Owner, "vol1") + exporter.observation.SessionState = target.SessionActive + if err := manager.DisableTarget(context.Background(), create.Owner, "vol1"); err != nil { + t.Fatalf("disable: %v", err) + } + afterDisable := activeCredential(t, store, create.Owner, "vol1") + if afterDisable.ID != before.ID || afterDisable.State != "active" { + t.Fatalf("disable changed credential: before=%#v after=%#v", before, afterDisable) + } + if err := manager.EnableTarget(context.Background(), create.Owner, "vol1"); err != nil { + t.Fatalf("enable: %v", err) + } + volume, err := getVolume(t, store, create.Owner, "vol1") + if err != nil { + t.Fatal(err) + } + if volume.State != "exported" || exporter.exportCalls != 2 { + t.Fatalf("volume = %#v export calls = %d", volume, exporter.exportCalls) + } + var export state.Export + if err := store.Transact(context.Background(), func(tx state.Tx) error { + value, err := tx.GetExport(create.Owner, "vol1") + export = value + return err + }); err != nil { + t.Fatal(err) + } + if export.State != "exported" { + t.Fatalf("export = %#v", export) + } + credential := activeCredential(t, store, create.Owner, "vol1") + if credential.State != "active" || credential.ID != before.ID { + t.Fatalf("credential = %#v, before = %#v", credential, before) + } +} + +func TestDisableTargetForceUnexportsAndKeepsDefinition(t *testing.T) { + manager, _, exporter, _, store := newTestManager(t) + create := createRequest(t, "vol1", "resource-1", 64<<20, true) + if _, err := manager.Run(context.Background(), create); err != nil { + t.Fatalf("create: %v", err) + } + exporter.observation.SessionState = target.SessionActive + for attempt := 0; attempt < 2; attempt++ { + if err := manager.DisableTarget(context.Background(), create.Owner, "vol1"); err != nil { + t.Fatalf("disable attempt %d: %v", attempt+1, err) + } + } + if exporter.unexportCalls != 2 { + t.Fatalf("unexport calls = %d, want 2", exporter.unexportCalls) + } + volume, err := getVolume(t, store, create.Owner, "vol1") + if err != nil { + t.Fatal(err) + } + if volume.State != "available" { + t.Fatalf("volume state = %q", volume.State) + } + var export state.Export + if err := store.Transact(context.Background(), func(tx state.Tx) error { + value, err := tx.GetExport(create.Owner, "vol1") + export = value + return err + }); err != nil { + t.Fatal(err) + } + if export.State != "unexported" || export.TargetName == "" || export.Initiator == "" { + t.Fatalf("definition = %#v", export) + } + credential := activeCredential(t, store, create.Owner, "vol1") + if credential.State != "active" || credential.ID == "" { + t.Fatalf("credential = %#v", credential) + } +} diff --git a/internal/operations/export.go b/internal/operations/export.go index 8bc9a69..8768464 100644 --- a/internal/operations/export.go +++ b/internal/operations/export.go @@ -80,9 +80,12 @@ func (manager *Manager) runUnexport(ctx context.Context, request Request, reques export.UpdatedAt = now exportPointer = &export } - credentialRecord, err := manager.supersedeCredential(ctx, volume) - if err != nil { - return manager.failOperation(ctx, operation, &volume, err, request.RequestID) + var credentialRecord *state.Credential + if !intent.KeepCredential { + credentialRecord, err = manager.supersedeCredential(ctx, volume) + if err != nil { + return manager.failOperation(ctx, operation, &volume, err, request.RequestID) + } } return manager.succeedOperation(ctx, operation, volume, exportPointer, credentialRecord, request.RequestID) } diff --git a/internal/operations/intents.go b/internal/operations/intents.go index 63d0205..83df313 100644 --- a/internal/operations/intents.go +++ b/internal/operations/intents.go @@ -26,10 +26,19 @@ type ExportIntent struct { RotateCredential bool `json:"rotate_credential,omitempty"` Force bool `json:"force,omitempty"` AttachmentID string `json:"attachment_id,omitempty"` + // RequestID makes an explicit admin enable a distinct operation when the + // same target is disabled and enabled again later. + RequestID string `json:"request_id,omitempty"` } type UnexportIntent struct { Force bool `json:"force"` + // RequestID makes an explicit admin disable a distinct operation when the + // same volume is exported and disabled again later. + RequestID string `json:"request_id,omitempty"` + // KeepCredential preserves the active CHAP credential for a reversible + // admin disable. Normal unexport behavior supersedes it. + KeepCredential bool `json:"keep_credential,omitempty"` } type ResizeIntent struct { diff --git a/internal/operations/manager.go b/internal/operations/manager.go index 2d47cd8..6e71ce9 100644 --- a/internal/operations/manager.go +++ b/internal/operations/manager.go @@ -53,6 +53,53 @@ func New(store *state.Store, lvmBackend lvm.Backend, iscsi, nvmet target.Exporte } } +// DisableTarget force-unexports a volume's target without deleting the volume +// or export row. The unique request intent prevents a later disable after a +// re-export from being mistaken for the previous idempotent unexport. +func (manager *Manager) DisableTarget(ctx context.Context, owner, volumeID string) error { + requestID := "admin-ui-disable-" + uuid.NewString() + intent, err := json.Marshal(UnexportIntent{Force: true, KeepCredential: true, RequestID: requestID}) + if err != nil { + return err + } + _, err = manager.Run(ctx, Request{ + Owner: owner, VolumeID: volumeID, Action: ActionUnexport, + Intent: intent, RequestID: requestID, + }) + return err +} + +// EnableTarget re-exports an existing iSCSI export definition after it was +// disabled. It reuses the stored target/initiator/portal definition and +// creates a new credential when the previous unexport superseded it. +func (manager *Manager) EnableTarget(ctx context.Context, owner, volumeID string) error { + var export state.Export + if err := manager.store.Transact(ctx, func(tx state.Tx) error { + value, err := tx.GetExport(owner, volumeID) + if err != nil { + return err + } + export = value + return nil + }); err != nil { + return err + } + requestID := "admin-ui-enable-" + uuid.NewString() + intent, err := json.Marshal(ExportIntent{ + Protocol: export.Protocol, Initiator: export.Initiator, + TargetName: export.TargetName, Portal: export.Portal, + RequestID: requestID, + }) + if err != nil { + return err + } + _, err = manager.Run(ctx, Request{ + Owner: owner, VolumeID: volumeID, Action: ActionExport, + Intent: intent, RequestID: requestID, + }) + return err +} + func (manager *Manager) Run(ctx context.Context, request Request) (Result, error) { if ctx == nil { ctx = context.Background() diff --git a/internal/operations/manager_test.go b/internal/operations/manager_test.go index 1b14476..847f721 100644 --- a/internal/operations/manager_test.go +++ b/internal/operations/manager_test.go @@ -128,15 +128,16 @@ func (backend *fakeLVM) Eligibility(context.Context, string) error { } type fakeExporter struct { - mu sync.Mutex - exports map[string]target.ExportResult - observation target.ExportObservation - exportErr error - unexportErr error - deleteErr error - exportCalls int - resizeCalls int - resizeErr error + mu sync.Mutex + exports map[string]target.ExportResult + observation target.ExportObservation + exportErr error + unexportErr error + deleteErr error + exportCalls int + unexportCalls int + resizeCalls int + resizeErr error } func newFakeExporter() *fakeExporter { @@ -165,6 +166,7 @@ func (exporter *fakeExporter) Export(_ context.Context, spec target.ExportSpec, func (exporter *fakeExporter) Unexport(_ context.Context, spec target.ExportSpec, _ bool) error { exporter.mu.Lock() defer exporter.mu.Unlock() + exporter.unexportCalls++ if exporter.unexportErr != nil { return exporter.unexportErr } diff --git a/internal/state/status.go b/internal/state/status.go new file mode 100644 index 0000000..32ae8f2 --- /dev/null +++ b/internal/state/status.go @@ -0,0 +1,215 @@ +package state + +import "database/sql" + +// VolumeStatus is the joined read model used by the local admin UI. Related +// rows are nil when the corresponding observation or export does not exist. +type VolumeStatus struct { + Volume + Export *Export + Observation *Observation + ExportObservation *ExportObservation +} + +var volumeStates = []string{ + "creating", "available", "exporting", "exported", "unexporting", + "resizing", "deleting", "failed", "drift", +} + +const volumeStatusColumns = `v.owner, v.volume_id, v.resource_uuid, v.lv_name, v.vg_name, + v.requested_size_bytes, v.actual_size_bytes, v.state, v.drift_reasons, + v.last_operation_id, v.error_code, v.legacy_identity, v.created_at, v.updated_at, + e.owner, e.volume_id, e.protocol, e.initiator, e.target_name, e.lun, e.nsid, + e.portal, e.state, e.created_at, e.updated_at, + o.owner, o.volume_id, o.lv_present, o.observed_size_bytes, o.device_path, + o.observed_at, o.source, + x.owner, x.volume_id, x.target_present, x.namespace_present, x.acl_present, + x.active_sessions, x.session_state, x.observed_initiator, x.observed_at, x.source` + +func (tx *sqlTx) ListVolumeStatuses(limit int) ([]VolumeStatus, error) { + if limit <= 0 { + limit = 256 + } + if limit > 256 { + limit = 256 + } + rows, err := tx.tx.QueryContext(tx.ctx, ` + SELECT `+volumeStatusColumns+` + FROM volumes v + LEFT JOIN exports e + ON e.owner = v.owner AND e.volume_id = v.volume_id + LEFT JOIN volume_observations o + ON o.owner = v.owner AND o.volume_id = v.volume_id + LEFT JOIN export_observations x + ON x.owner = v.owner AND x.volume_id = v.volume_id + WHERE v.state != 'deleted' + ORDER BY v.updated_at DESC, v.owner, v.volume_id + LIMIT ?`, limit) + if err != nil { + return nil, mapError(err) + } + defer func() { _ = rows.Close() }() + + statuses := make([]VolumeStatus, 0, limit) + for rows.Next() { + status, err := scanVolumeStatus(rows) + if err != nil { + return nil, err + } + statuses = append(statuses, status) + } + if err := rows.Err(); err != nil { + return nil, mapError(err) + } + return statuses, nil +} + +func scanVolumeStatus(row rowScanner) (VolumeStatus, error) { + var status VolumeStatus + var volume Volume + var actual sql.NullInt64 + var driftReasons, lastOperationID, errorCode, legacyIdentity sql.NullString + var volumeCreated, volumeUpdated string + + var eOwner, eVolumeID, eProtocol, eInitiator, eTarget, ePortal, eState sql.NullString + var eLUN, eNSID sql.NullInt64 + var eCreated, eUpdated sql.NullString + + var oOwner, oVolumeID sql.NullString + var oLVPresent sql.NullInt64 + var oSize sql.NullInt64 + var oDevicePath, oObservedAt, oSource sql.NullString + + var xOwner, xVolumeID sql.NullString + var xTarget, xNamespace, xACL, xSessions sql.NullInt64 + var xSessionState, xObservedInitiator, xObservedAt, xSource sql.NullString + + if err := row.Scan( + &volume.Owner, &volume.VolumeID, &volume.ResourceUUID, &volume.LVName, &volume.VGName, + &volume.RequestedSizeBytes, &actual, &volume.State, &driftReasons, + &lastOperationID, &errorCode, &legacyIdentity, &volumeCreated, &volumeUpdated, + &eOwner, &eVolumeID, &eProtocol, &eInitiator, &eTarget, &eLUN, &eNSID, + &ePortal, &eState, &eCreated, &eUpdated, + &oOwner, &oVolumeID, &oLVPresent, &oSize, &oDevicePath, &oObservedAt, &oSource, + &xOwner, &xVolumeID, &xTarget, &xNamespace, &xACL, &xSessions, + &xSessionState, &xObservedInitiator, &xObservedAt, &xSource, + ); err != nil { + return VolumeStatus{}, notFound(err) + } + + if actual.Valid { + volume.ActualSizeBytes = actual.Int64 + } + volume.LastOperationID = stringOrEmpty(lastOperationID) + volume.ErrorCode = stringOrEmpty(errorCode) + var err error + if volume.DriftReasons, err = decodeStrings(driftReasons); err != nil { + return VolumeStatus{}, err + } + if volume.LegacyIdentity, err = decodeMap(legacyIdentity); err != nil { + return VolumeStatus{}, err + } + if volume.CreatedAt, err = parseTime(volumeCreated); err != nil { + return VolumeStatus{}, err + } + if volume.UpdatedAt, err = parseTime(volumeUpdated); err != nil { + return VolumeStatus{}, err + } + status.Volume = volume + + if eOwner.Valid { + export := &Export{ + Owner: stringOrEmpty(eOwner), VolumeID: stringOrEmpty(eVolumeID), + Protocol: stringOrEmpty(eProtocol), Initiator: stringOrEmpty(eInitiator), + TargetName: stringOrEmpty(eTarget), LUN: nullableInt32(eLUN), NSID: nullableInt32(eNSID), + Portal: stringOrEmpty(ePortal), State: stringOrEmpty(eState), + } + if export.CreatedAt, err = parseTime(stringOrEmpty(eCreated)); err != nil { + return VolumeStatus{}, err + } + if export.UpdatedAt, err = parseTime(stringOrEmpty(eUpdated)); err != nil { + return VolumeStatus{}, err + } + status.Export = export + } + + if oOwner.Valid { + observation := &Observation{ + Owner: stringOrEmpty(oOwner), VolumeID: stringOrEmpty(oVolumeID), + LVPresent: oLVPresent.Valid && oLVPresent.Int64 != 0, + DevicePath: stringOrEmpty(oDevicePath), Source: stringOrEmpty(oSource), + } + if oSize.Valid { + observation.ObservedSizeBytes = oSize.Int64 + } + if observation.ObservedAt, err = parseTime(stringOrEmpty(oObservedAt)); err != nil { + return VolumeStatus{}, err + } + status.Observation = observation + } + + if xOwner.Valid { + observation := &ExportObservation{ + Owner: stringOrEmpty(xOwner), VolumeID: stringOrEmpty(xVolumeID), + TargetPresent: xTarget.Valid && xTarget.Int64 != 0, + NamespacePresent: xNamespace.Valid && xNamespace.Int64 != 0, + ACLPresent: xACL.Valid && xACL.Int64 != 0, + SessionState: stringOrEmpty(xSessionState), + ObservedInitiator: stringOrEmpty(xObservedInitiator), + Source: stringOrEmpty(xSource), + } + if xSessions.Valid { + observation.ActiveSessions = int32(xSessions.Int64) //nolint:gosec // session counts are kernel-sized and bounded by LIO/nvmet + } + if observation.ObservedAt, err = parseTime(stringOrEmpty(xObservedAt)); err != nil { + return VolumeStatus{}, err + } + status.ExportObservation = observation + } + return status, nil +} + +func (tx *sqlTx) VolumeStateCounts() (map[string]int, error) { + counts := make(map[string]int, len(volumeStates)) + for _, state := range volumeStates { + counts[state] = 0 + } + rows, err := tx.tx.QueryContext(tx.ctx, "SELECT state, COUNT(*) FROM volumes WHERE state != 'deleted' GROUP BY state") + if err != nil { + return nil, mapError(err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var state string + var count int + if err := rows.Scan(&state, &count); err != nil { + return nil, mapError(err) + } + counts[state] = count + } + if err := rows.Err(); err != nil { + return nil, mapError(err) + } + return counts, nil +} + +func (tx *sqlTx) ListRecentOperations(limit int) ([]Operation, error) { + if limit <= 0 { + limit = 64 + } + if limit > 64 { + limit = 64 + } + return tx.queryOperations("SELECT "+operationColumns+` FROM operations + WHERE state IN ('running', 'failed', 'needs_attention') + ORDER BY updated_at DESC, id DESC LIMIT ?`, limit) +} + +func (tx *sqlTx) OperationCount() (int, error) { + var count int + if err := tx.tx.QueryRowContext(tx.ctx, `SELECT COUNT(*) FROM operations + WHERE state IN ('running', 'failed', 'needs_attention')`).Scan(&count); err != nil { + return 0, mapError(err) + } + return count, nil +} diff --git a/internal/state/status_test.go b/internal/state/status_test.go new file mode 100644 index 0000000..6335151 --- /dev/null +++ b/internal/state/status_test.go @@ -0,0 +1,148 @@ +package state + +import ( + "context" + "testing" + "time" +) + +func TestVolumeStatusQueries(t *testing.T) { + store := openTestStore(t) + runTx(t, store, func(tx Tx) error { + addVG(t, tx, "vg1") + addVolume(t, tx, "owner-a", "vol1", "vg1") + if err := tx.PutExport(Export{ + Owner: "owner-a", VolumeID: "vol1", Protocol: "iscsi", Initiator: "iqn.i", + TargetName: "iqn.t", Portal: "127.0.0.1:3260", State: "exported", + CreatedAt: testTime, UpdatedAt: testTime, + }); err != nil { + return err + } + if err := tx.PutObservation(Observation{ + Owner: "owner-a", VolumeID: "vol1", LVPresent: true, + ObservedSizeBytes: 1 << 30, DevicePath: "/dev/vg1/lv-vol1", + ObservedAt: testTime, Source: "lvm", + }); err != nil { + return err + } + return tx.PutExportObservation(ExportObservation{ + Owner: "owner-a", VolumeID: "vol1", TargetPresent: true, + NamespacePresent: true, ACLPresent: true, ActiveSessions: 1, + SessionState: "active", ObservedInitiator: "iqn.i", + ObservedAt: testTime, Source: "lio", + }) + }) + var statuses []VolumeStatus + if err := store.Transact(context.Background(), func(tx Tx) error { + values, err := tx.ListVolumeStatuses(10) + statuses = values + return err + }); err != nil { + t.Fatal(err) + } + if len(statuses) != 1 { + t.Fatalf("statuses = %d", len(statuses)) + } + status := statuses[0] + if status.Export == nil || status.Observation == nil || status.ExportObservation == nil { + t.Fatalf("missing joined rows: %#v", status) + } + if status.Observation.ObservedSizeBytes != 1<<30 || !status.ExportObservation.ACLPresent { + t.Fatalf("joined values = %#v", status) + } + var counts map[string]int + if err := store.Transact(context.Background(), func(tx Tx) error { + values, err := tx.VolumeStateCounts() + counts = values + return err + }); err != nil { + t.Fatal(err) + } + if counts["available"] != 1 || counts["drift"] != 0 { + t.Fatalf("counts = %#v", counts) + } +} + +func TestOperationStatusQueries(t *testing.T) { + store := openTestStore(t) + now := time.Now().UTC() + runTx(t, store, func(tx Tx) error { + for _, operation := range []Operation{ + {ID: "running", Owner: "o", VolumeID: "v", ResourceUUID: "r", Action: "create", RequestHash: "h1", IntentJSON: "{}", State: "running", Attempt: 1, MaxAttempts: 3, DeadlineAt: now.Add(time.Hour), CreatedAt: now, UpdatedAt: now}, + {ID: "failed", Owner: "o", VolumeID: "v", ResourceUUID: "r", Action: "create", RequestHash: "h2", IntentJSON: "{}", State: "failed", Attempt: 3, MaxAttempts: 3, DeadlineAt: now, CreatedAt: now, UpdatedAt: now}, + {ID: "succeeded", Owner: "o", VolumeID: "v", ResourceUUID: "r", Action: "create", RequestHash: "h3", IntentJSON: "{}", State: "succeeded", Attempt: 1, MaxAttempts: 3, DeadlineAt: now, CreatedAt: now, UpdatedAt: now}, + } { + if err := tx.PutOperation(operation); err != nil { + return err + } + } + return nil + }) + var operations []Operation + var count int + if err := store.Transact(context.Background(), func(tx Tx) error { + values, err := tx.ListRecentOperations(10) + operations = values + if err != nil { + return err + } + count, err = tx.OperationCount() + return err + }); err != nil { + t.Fatal(err) + } + if len(operations) != 2 || count != 2 { + t.Fatalf("operations = %#v count=%d", operations, count) + } + for _, operation := range operations { + if operation.State == "succeeded" { + t.Fatalf("unexpected succeeded operation: %#v", operation) + } + } +} + +func TestEmptyStatusQueries(t *testing.T) { + store := openTestStore(t) + if err := store.Transact(context.Background(), func(tx Tx) error { + statuses, err := tx.ListVolumeStatuses(0) + if err != nil || len(statuses) != 0 { + t.Fatalf("empty statuses = %#v %v", statuses, err) + } + counts, err := tx.VolumeStateCounts() + if err != nil || counts["exported"] != 0 { + t.Fatalf("empty counts = %#v %v", counts, err) + } + operations, err := tx.ListRecentOperations(0) + if err != nil || len(operations) != 0 { + t.Fatalf("empty operations = %#v %v", operations, err) + } + count, err := tx.OperationCount() + if err != nil || count != 0 { + t.Fatalf("empty operation count = %d %v", count, err) + } + return nil + }); err != nil { + t.Fatal(err) + } +} + +func TestVolumeStatusWithoutRelatedRows(t *testing.T) { + store := openTestStore(t) + runTx(t, store, func(tx Tx) error { + addVG(t, tx, "vg1") + addVolume(t, tx, "owner", "vol1", "vg1") + return nil + }) + if err := store.Transact(context.Background(), func(tx Tx) error { + statuses, err := tx.ListVolumeStatuses(1) + if err != nil { + return err + } + if len(statuses) != 1 || statuses[0].Export != nil || statuses[0].Observation != nil || statuses[0].ExportObservation != nil { + t.Fatalf("unexpected related rows: %#v", statuses) + } + return nil + }); err != nil { + t.Fatal(err) + } +} diff --git a/internal/state/store.go b/internal/state/store.go index a1a9184..954ddb1 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -247,6 +247,8 @@ type Tx interface { GetVolume(owner, volumeID string) (Volume, error) GetVolumeByResourceUUID(resourceUUID string) (Volume, error) ListVolumes(owner string, page Page) ([]Volume, error) + ListVolumeStatuses(limit int) ([]VolumeStatus, error) + VolumeStateCounts() (map[string]int, error) PutVolume(Volume) error DeleteVolume(owner, volumeID string) error @@ -275,6 +277,8 @@ type Tx interface { GetOperationByID(id string) (Operation, error) ListOperations(owner, volumeID string) ([]Operation, error) ListRunningOperations() ([]Operation, error) + ListRecentOperations(limit int) ([]Operation, error) + OperationCount() (int, error) PutOperation(Operation) error PutGrant(Grant) error diff --git a/internal/target/iscsi.go b/internal/target/iscsi.go index 9ddd20d..92c4ff2 100644 --- a/internal/target/iscsi.go +++ b/internal/target/iscsi.go @@ -15,7 +15,8 @@ import ( // ISCSI implements the target.Exporter interface directly against LIO // configfs. One target and TPG are used per volume. type ISCSI struct { - fs FileSystem + fs FileSystem + procNetDir string } var _ Exporter = (*ISCSI)(nil) @@ -24,7 +25,7 @@ func NewISCSI(fs FileSystem) *ISCSI { if fs == nil { fs = NewConfigFS("") } - return &ISCSI{fs: fs} + return &ISCSI{fs: fs, procNetDir: "/proc/net"} } func (adapter *ISCSI) Export(ctx context.Context, spec ExportSpec, credential Credential) (ExportResult, error) { diff --git a/internal/target/iscsi_metrics.go b/internal/target/iscsi_metrics.go new file mode 100644 index 0000000..1f6dc8e --- /dev/null +++ b/internal/target/iscsi_metrics.go @@ -0,0 +1,617 @@ +package target + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path" + "sort" + "strconv" + "strings" +) + +// ISCSIMetricsProvider exposes the live LIO counters used by the local admin +// UI. It is deliberately separate from Exporter: metrics are read-only and +// must never make an export operation fail. +type ISCSIMetricsProvider interface { + Metrics(context.Context) (ISCSIMetrics, error) +} + +var _ ISCSIMetricsProvider = (*ISCSI)(nil) + +type ISCSIMetrics struct { + Targets []ISCSITargetMetrics + Connections []ISCSIClientConnection + ConnectionError string +} + +type ISCSITargetMetrics struct { + Target string + Sessions int64 + Nodes int64 + Portals int64 + Login ISCSILoginMetrics + Logout ISCSILogoutMetrics + SessionErrors ISCSISessionErrorMetrics + Initiators []ISCSIInitiatorMetrics + LUNs []ISCSILUNMetrics + ClientAddresses []string + PortalPorts []int + Error string +} + +type ISCSIClientConnection struct { + ClientAddress string + ClientPort int + LocalAddress string + LocalPort int + Target string + Initiator string + Ambiguous bool +} + +type ISCSILoginMetrics struct { + Accepts int64 + AuthenticateFails int64 + AuthorizeFails int64 + NegotiateFails int64 + Redirects int64 + OtherFails int64 + TargetLoginFails int64 +} + +type ISCSILogoutMetrics struct { + Normal int64 + Abnormal int64 +} + +type ISCSISessionErrorMetrics struct { + FormatErrors int64 + ConnectionErrors int64 + DigestErrors int64 +} + +type ISCSIInitiatorMetrics struct { + Initiator string + RXBytes int64 + TXBytes int64 + CommandPDUs int64 + ResponsePDUs int64 + ConnectionTimeoutErrors int64 + ConnectionDigestErrors int64 + ClientAddresses []string +} + +type ISCSILUNMetrics struct { + LUN int32 + ResourceUUID string + Commands int64 + ReadBytes int64 + WriteBytes int64 + Resets int64 + AbortsComplete int64 + AbortsNoTask int64 + Status string + StateBit string + BusyCount int64 + InCommands int64 + HighPriorityInCommands int64 + Error string +} + +// Metrics reads the target, initiator, and LUN statistics that LIO exposes in +// configfs. Missing optional attributes are represented as zero; a target that +// disappears between ReadDir and the attribute reads is skipped. The caller +// can treat an error as a non-fatal metrics-unavailable condition. +func (adapter *ISCSI) Metrics(ctx context.Context) (ISCSIMetrics, error) { + result := ISCSIMetrics{Targets: []ISCSITargetMetrics{}, Connections: []ISCSIClientConnection{}} + if err := ctx.Err(); err != nil { + return result, err + } + entries, err := adapter.fs.ReadDir("target/iscsi") + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return result, nil + } + return result, fmt.Errorf("target: read iSCSI metrics: %w", err) + } + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return result, err + } + if !entry.IsDir() || entry.Name() == "discovery_auth" || entry.Name() == "version" { + continue + } + metrics, ok := adapter.targetMetrics(entry.Name()) + if ok { + result.Targets = append(result.Targets, metrics) + } + } + sort.Slice(result.Targets, func(left, right int) bool { + return result.Targets[left].Target < result.Targets[right].Target + }) + result.Connections, result.ConnectionError = adapter.clientConnections(result.Targets) + return result, nil +} + +func (adapter *ISCSI) targetMetrics(name string) (ISCSITargetMetrics, bool) { + targetPath := path.Join("target/iscsi", name) + tpgtPath := path.Join(targetPath, "tpgt_1") + if !adapter.fs.Exists(tpgtPath) { + return ISCSITargetMetrics{}, false + } + metrics := ISCSITargetMetrics{ + Target: name, Initiators: []ISCSIInitiatorMetrics{}, LUNs: []ISCSILUNMetrics{}, + ClientAddresses: []string{}, PortalPorts: []int{}, + } + metrics.PortalPorts = adapter.portalPorts(tpgtPath) + adapter.requiredMetric(path.Join(targetPath, "fabric_statistics/iscsi_instance/sessions"), &metrics.Sessions, &metrics.Error) + adapter.requiredMetric(path.Join(targetPath, "fabric_statistics/iscsi_instance/nodes"), &metrics.Nodes, &metrics.Error) + adapter.requiredMetric(path.Join(targetPath, "fabric_statistics/iscsi_instance/portals"), &metrics.Portals, &metrics.Error) + + metrics.Login = ISCSILoginMetrics{ + Accepts: adapter.optionalMetric(path.Join(targetPath, "fabric_statistics/iscsi_login_stats/accepts")), + AuthenticateFails: adapter.optionalMetric(path.Join(targetPath, "fabric_statistics/iscsi_login_stats/authenticate_fails")), + AuthorizeFails: adapter.optionalMetric(path.Join(targetPath, "fabric_statistics/iscsi_login_stats/authorize_fails")), + NegotiateFails: adapter.optionalMetric(path.Join(targetPath, "fabric_statistics/iscsi_login_stats/negotiate_fails")), + Redirects: adapter.optionalMetric(path.Join(targetPath, "fabric_statistics/iscsi_login_stats/redirects")), + OtherFails: adapter.optionalMetric(path.Join(targetPath, "fabric_statistics/iscsi_login_stats/other_fails")), + TargetLoginFails: adapter.optionalMetric(path.Join(targetPath, "fabric_statistics/iscsi_tgt_attr/login_fails")), + } + metrics.Logout = ISCSILogoutMetrics{ + Normal: adapter.optionalMetric(path.Join(targetPath, "fabric_statistics/iscsi_logout_stats/normal_logouts")), + Abnormal: adapter.optionalMetric(path.Join(targetPath, "fabric_statistics/iscsi_logout_stats/abnormal_logouts")), + } + metrics.SessionErrors = ISCSISessionErrorMetrics{ + FormatErrors: adapter.optionalMetric(path.Join(targetPath, "fabric_statistics/iscsi_sess_err/format_errors")), + ConnectionErrors: adapter.optionalMetric(path.Join(targetPath, "fabric_statistics/iscsi_sess_err/cxn_errors")), + DigestErrors: adapter.optionalMetric(path.Join(targetPath, "fabric_statistics/iscsi_sess_err/digest_errors")), + } + + if entries, err := adapter.fs.ReadDir(path.Join(tpgtPath, "acls")); err != nil { + if !errors.Is(err, os.ErrNotExist) { + appendMetricError(&metrics.Error, err) + } + } else { + for _, entry := range entries { + if !entry.IsDir() { + continue + } + aclPath := path.Join(tpgtPath, "acls", entry.Name()) + initiator := adapter.initiatorMetrics(entry.Name(), aclPath) + initiator.ClientAddresses = adapter.clientAddresses(aclPath) + metrics.Initiators = append(metrics.Initiators, initiator) + metrics.ClientAddresses = appendUniqueStrings(metrics.ClientAddresses, initiator.ClientAddresses...) + } + sort.Strings(metrics.ClientAddresses) + sort.Slice(metrics.Initiators, func(left, right int) bool { + return metrics.Initiators[left].Initiator < metrics.Initiators[right].Initiator + }) + } + + if entries, err := adapter.fs.ReadDir(path.Join(tpgtPath, "lun")); err != nil { + if !errors.Is(err, os.ErrNotExist) { + appendMetricError(&metrics.Error, err) + } + } else { + for _, entry := range entries { + if !entry.IsDir() { + continue + } + index, err := parseLUNIndex(entry.Name()) + if err != nil { + continue + } + metrics.LUNs = append(metrics.LUNs, + adapter.lunMetrics(index, path.Join(tpgtPath, "lun", entry.Name()))...) + } + sort.Slice(metrics.LUNs, func(left, right int) bool { + if metrics.LUNs[left].LUN == metrics.LUNs[right].LUN { + return metrics.LUNs[left].ResourceUUID < metrics.LUNs[right].ResourceUUID + } + return metrics.LUNs[left].LUN < metrics.LUNs[right].LUN + }) + } + return metrics, true +} + +func (adapter *ISCSI) initiatorMetrics(initiator, aclPath string) ISCSIInitiatorMetrics { + stats := path.Join(aclPath, "fabric_statistics/iscsi_sess_stats") + return ISCSIInitiatorMetrics{ + Initiator: initiator, + RXBytes: adapter.optionalMetric(path.Join(stats, "rxdata_octs")), + TXBytes: adapter.optionalMetric(path.Join(stats, "txdata_octs")), + CommandPDUs: adapter.optionalMetric(path.Join(stats, "cmd_pdus")), + ResponsePDUs: adapter.optionalMetric(path.Join(stats, "rsp_pdus")), + ConnectionTimeoutErrors: adapter.optionalMetric(path.Join(stats, "conn_timeout_errors")), + ConnectionDigestErrors: adapter.optionalMetric(path.Join(stats, "conn_digest_errors")), + } +} + +func (adapter *ISCSI) lunMetrics(index int32, lunPath string) []ISCSILUNMetrics { + entries, err := adapter.fs.ReadDir(lunPath) + if err != nil { + return nil + } + result := make([]ISCSILUNMetrics, 0, len(entries)) + for _, entry := range entries { + if !isLUNResource(entry) { + continue + } + metrics := ISCSILUNMetrics{ + LUN: index, ResourceUUID: entry.Name(), + Status: "unknown", StateBit: "unknown", + } + statsPath := path.Join(lunPath, entry.Name(), "statistics") + adapter.requiredMetric(path.Join(statsPath, "scsi_lu/num_cmds"), &metrics.Commands, &metrics.Error) + readMB := adapter.optionalMetric(path.Join(statsPath, "scsi_lu/read_mbytes")) + writeMB := adapter.optionalMetric(path.Join(statsPath, "scsi_lu/write_mbytes")) + metrics.ReadBytes = mbytes(readMB) + metrics.WriteBytes = mbytes(writeMB) + metrics.Resets = adapter.optionalMetric(path.Join(statsPath, "scsi_lu/resets")) + metrics.AbortsComplete = adapter.optionalMetric(path.Join(statsPath, "scsi_tgt_dev/aborts_complete")) + metrics.AbortsNoTask = adapter.optionalMetric(path.Join(statsPath, "scsi_tgt_dev/aborts_no_task")) + metrics.Resets += adapter.optionalMetric(path.Join(statsPath, "scsi_tgt_dev/resets")) + metrics.Status = adapter.optionalString(path.Join(statsPath, "scsi_lu/status"), "unknown") + metrics.StateBit = adapter.optionalString(path.Join(statsPath, "scsi_lu/state_bit"), "unknown") + + portStats := path.Join(lunPath, "statistics/scsi_tgt_port") + metrics.BusyCount = adapter.optionalMetric(path.Join(portStats, "busy_count")) + metrics.InCommands = adapter.optionalMetric(path.Join(portStats, "in_cmds")) + metrics.HighPriorityInCommands = adapter.optionalMetric(path.Join(portStats, "hs_in_cmds")) + result = append(result, metrics) + } + return result +} + +func (adapter *ISCSI) requiredMetric(rel string, target *int64, metricError *string) { + value, err := adapter.statInt(rel) + if err != nil { + appendMetricError(metricError, err) + return + } + *target = value +} + +func (adapter *ISCSI) optionalMetric(rel string) int64 { + value, err := adapter.statInt(rel) + if err != nil { + return 0 + } + return value +} + +func (adapter *ISCSI) optionalString(rel, fallback string) string { + value, err := adapter.fs.ReadFile(rel) + if err != nil { + return fallback + } + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + return value +} + +func (adapter *ISCSI) statInt(rel string) (int64, error) { + value, err := adapter.fs.ReadFile(rel) + if err != nil { + return 0, err + } + parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil { + return 0, fmt.Errorf("target: parse configfs stat %q: %w", rel, err) + } + return parsed, nil +} + +func appendMetricError(target *string, err error) { + if *target != "" { + *target += "; " + } + *target += err.Error() +} + +func parseLUNIndex(name string) (int32, error) { + if !strings.HasPrefix(name, "lun_") { + return 0, fmt.Errorf("target: invalid LUN directory %q", name) + } + value, err := strconv.ParseInt(strings.TrimPrefix(name, "lun_"), 10, 32) + if err != nil { + return 0, fmt.Errorf("target: invalid LUN directory %q: %w", name, err) + } + return int32(value), nil +} + +func isLUNResource(entry os.DirEntry) bool { + name := entry.Name() + if entry.IsDir() || entry.Type().IsRegular() || strings.HasPrefix(name, ".") { + return false + } + return true +} + +func mbytes(value int64) int64 { + if value <= 0 { + return 0 + } + return value * 1024 * 1024 +} + +func (adapter *ISCSI) portalPorts(tpgtPath string) []int { + entries, err := adapter.fs.ReadDir(path.Join(tpgtPath, "np")) + if err != nil { + return []int{} + } + ports := make([]int, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + _, portText, err := net.SplitHostPort(entry.Name()) + if err != nil { + continue + } + port, err := strconv.Atoi(portText) + if err != nil || port < 1 || port > 65535 { + continue + } + ports = appendUniqueInts(ports, port) + } + sort.Ints(ports) + return ports +} + +func (adapter *ISCSI) clientAddresses(aclPath string) []string { + data, err := adapter.fs.ReadFile(path.Join(aclPath, "info")) + if err != nil { + return []string{} + } + addresses := []string{} + for _, line := range strings.Split(data, "\n") { + fields := strings.Fields(line) + if len(fields) < 2 || fields[0] != "Address" { + continue + } + address := strings.TrimSuffix(fields[1], ",") + if net.ParseIP(address) != nil { + addresses = appendUniqueStrings(addresses, address) + } + } + sort.Strings(addresses) + return addresses +} + +type tcpConnection struct { + localAddress string + localPort int + clientAddress string + clientPort int +} + +func (adapter *ISCSI) clientConnections(targets []ISCSITargetMetrics) ([]ISCSIClientConnection, string) { + ports := make(map[int]struct{}) + for _, target := range targets { + for _, port := range target.PortalPorts { + ports[port] = struct{}{} + } + } + if len(ports) == 0 { + return []ISCSIClientConnection{}, "" + } + connections, err := adapter.readProcNetConnections(ports) + if err != nil { + return []ISCSIClientConnection{}, err.Error() + } + result := make([]ISCSIClientConnection, 0, len(connections)) + for _, connection := range connections { + view := ISCSIClientConnection{ + ClientAddress: connection.clientAddress, + ClientPort: connection.clientPort, + LocalAddress: connection.localAddress, + LocalPort: connection.localPort, + } + type match struct { + target string + initiator string + } + matches := []match{} + for _, target := range targets { + if !containsInt(target.PortalPorts, connection.localPort) { + continue + } + for _, initiator := range target.Initiators { + if containsString(initiator.ClientAddresses, connection.clientAddress) { + matches = append(matches, match{target: target.Target, initiator: initiator.Initiator}) + } + } + } + switch len(matches) { + case 1: + view.Target = matches[0].target + view.Initiator = matches[0].initiator + case 0: + // ACL info can be unavailable briefly while a session is being + // established. If exactly one target owns this portal port, it is + // still unambiguous. + targetsOnPort := []ISCSITargetMetrics{} + for _, target := range targets { + if containsInt(target.PortalPorts, connection.localPort) { + targetsOnPort = append(targetsOnPort, target) + } + } + if len(targetsOnPort) == 1 { + view.Target = targetsOnPort[0].Target + if len(targetsOnPort[0].Initiators) == 1 { + view.Initiator = targetsOnPort[0].Initiators[0].Initiator + } + } + default: + view.Ambiguous = true + } + result = append(result, view) + } + sort.Slice(result, func(left, right int) bool { + if result[left].ClientAddress == result[right].ClientAddress { + if result[left].ClientPort == result[right].ClientPort { + return result[left].LocalPort < result[right].LocalPort + } + return result[left].ClientPort < result[right].ClientPort + } + return result[left].ClientAddress < result[right].ClientAddress + }) + return result, "" +} + +func (adapter *ISCSI) readProcNetConnections(ports map[int]struct{}) ([]tcpConnection, error) { + directory := adapter.procNetDir + if directory == "" { + directory = "/proc/net" + } + result := []tcpConnection{} + var lastErr error + for _, name := range []string{"tcp", "tcp6"} { + data, err := os.ReadFile(path.Join(directory, name)) //nolint:gosec // fixed tcp/tcp6 names under the configured proc directory + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + lastErr = err + } + continue + } + connections, err := parseProcNetTCP(data, ports) + if err != nil { + lastErr = err + continue + } + result = append(result, connections...) + } + if lastErr != nil { + return result, lastErr + } + return result, nil +} + +func parseProcNetTCP(data []byte, ports map[int]struct{}) ([]tcpConnection, error) { + result := []tcpConnection{} + for index, line := range strings.Split(string(data), "\n") { + if index == 0 || strings.TrimSpace(line) == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 4 || fields[3] != "01" { + continue + } + localAddress, localPort, err := parseProcEndpoint(fields[1]) + if err != nil { + return nil, err + } + clientAddress, clientPort, err := parseProcEndpoint(fields[2]) + if err != nil { + return nil, err + } + if _, wanted := ports[localPort]; !wanted || clientPort == 0 { + continue + } + result = append(result, tcpConnection{ + localAddress: localAddress, localPort: localPort, + clientAddress: clientAddress, clientPort: clientPort, + }) + } + return result, nil +} + +func parseProcEndpoint(value string) (string, int, error) { + colon := strings.LastIndexByte(value, ':') + if colon <= 0 || colon == len(value)-1 { + return "", 0, fmt.Errorf("target: invalid proc TCP endpoint %q", value) + } + address, err := parseProcAddress(value[:colon]) + if err != nil { + return "", 0, err + } + port, err := strconv.ParseUint(value[colon+1:], 16, 16) + if err != nil { + return "", 0, fmt.Errorf("target: invalid proc TCP port %q: %w", value[colon+1:], err) + } + return address, int(port), nil +} + +func parseProcAddress(value string) (string, error) { + switch len(value) { + case 8: + raw, err := strconv.ParseUint(value, 16, 32) + if err != nil { + return "", fmt.Errorf("target: invalid proc IPv4 address %q: %w", value, err) + } + octets := []byte{byte(raw), byte(raw >> 8), byte(raw >> 16), byte(raw >> 24)} //nolint:gosec // parsed with bitSize 32 + return net.IPv4(octets[0], octets[1], octets[2], octets[3]).String(), nil + case 32: + raw := make([]byte, 0, net.IPv6len) + for offset := 0; offset < len(value); offset += 8 { + word, err := strconv.ParseUint(value[offset:offset+8], 16, 32) + if err != nil { + return "", fmt.Errorf("target: invalid proc IPv6 address %q: %w", value, err) + } + raw = append(raw, byte(word), byte(word>>8), byte(word>>16), byte(word>>24)) //nolint:gosec // parsed with bitSize 32 + } + ip := net.IP(raw) + if ipv4 := ip.To4(); ipv4 != nil { + return ipv4.String(), nil + } + return ip.String(), nil + default: + return "", fmt.Errorf("target: invalid proc TCP address %q", value) + } +} + +func appendUniqueStrings(values []string, additions ...string) []string { + for _, addition := range additions { + found := false + for _, value := range values { + if value == addition { + found = true + break + } + } + if !found { + values = append(values, addition) + } + } + return values +} + +func appendUniqueInts(values []int, additions ...int) []int { + for _, addition := range additions { + found := false + for _, value := range values { + if value == addition { + found = true + break + } + } + if !found { + values = append(values, addition) + } + } + return values +} + +func containsString(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + +func containsInt(values []int, wanted int) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} diff --git a/internal/target/iscsi_metrics_test.go b/internal/target/iscsi_metrics_test.go new file mode 100644 index 0000000..5e8e1e1 --- /dev/null +++ b/internal/target/iscsi_metrics_test.go @@ -0,0 +1,202 @@ +package target + +import ( + "context" + "errors" + "os" + "path" + "testing" +) + +func writeMetricFile(t *testing.T, fs *ConfigFS, rel, value string) { + t.Helper() + if err := fs.Mkdir(path.Dir(rel)); err != nil { + t.Fatal(err) + } + if err := fs.Write(rel, value); err != nil { + t.Fatal(err) + } +} + +func TestISCSIMetricsReadsConfigFSStats(t *testing.T) { + adapter, fs := newTestISCSI(t) + spec := testSpec() + if _, err := adapter.Export(context.Background(), spec, testCredential()); err != nil { + t.Fatalf("Export: %v", err) + } + targetPath := path.Join("target/iscsi", spec.TargetName) + writeMetricFile(t, fs.ConfigFS, path.Join(targetPath, "fabric_statistics/iscsi_instance/sessions"), "1\n") + writeMetricFile(t, fs.ConfigFS, path.Join(targetPath, "fabric_statistics/iscsi_instance/nodes"), "1\n") + writeMetricFile(t, fs.ConfigFS, path.Join(targetPath, "fabric_statistics/iscsi_instance/portals"), "1\n") + writeMetricFile(t, fs.ConfigFS, path.Join(targetPath, "fabric_statistics/iscsi_login_stats/accepts"), "3\n") + writeMetricFile(t, fs.ConfigFS, path.Join(targetPath, "fabric_statistics/iscsi_login_stats/authenticate_fails"), "1\n") + writeMetricFile(t, fs.ConfigFS, path.Join(targetPath, "fabric_statistics/iscsi_login_stats/authorize_fails"), "2\n") + writeMetricFile(t, fs.ConfigFS, path.Join(targetPath, "fabric_statistics/iscsi_login_stats/negotiate_fails"), "3\n") + writeMetricFile(t, fs.ConfigFS, path.Join(targetPath, "fabric_statistics/iscsi_login_stats/other_fails"), "4\n") + writeMetricFile(t, fs.ConfigFS, path.Join(targetPath, "fabric_statistics/iscsi_tgt_attr/login_fails"), "10\n") + writeMetricFile(t, fs.ConfigFS, path.Join(targetPath, "fabric_statistics/iscsi_logout_stats/abnormal_logouts"), "2\n") + writeMetricFile(t, fs.ConfigFS, path.Join(targetPath, "fabric_statistics/iscsi_sess_err/format_errors"), "1\n") + writeMetricFile(t, fs.ConfigFS, path.Join(targetPath, "fabric_statistics/iscsi_sess_err/cxn_errors"), "2\n") + writeMetricFile(t, fs.ConfigFS, path.Join(targetPath, "fabric_statistics/iscsi_sess_err/digest_errors"), "3\n") + aclPath := path.Join(targetPath, "tpgt_1/acls", spec.Initiator) + writeMetricFile(t, fs.ConfigFS, path.Join(aclPath, "fabric_statistics/iscsi_sess_stats/rxdata_octs"), "12582912\n") + writeMetricFile(t, fs.ConfigFS, path.Join(aclPath, "fabric_statistics/iscsi_sess_stats/txdata_octs"), "1048576\n") + writeMetricFile(t, fs.ConfigFS, path.Join(aclPath, "fabric_statistics/iscsi_sess_stats/cmd_pdus"), "42\n") + writeMetricFile(t, fs.ConfigFS, path.Join(aclPath, "fabric_statistics/iscsi_sess_stats/rsp_pdus"), "40\n") + writeMetricFile(t, fs.ConfigFS, path.Join(aclPath, "fabric_statistics/iscsi_sess_stats/conn_timeout_errors"), "4\n") + writeMetricFile(t, fs.ConfigFS, path.Join(aclPath, "fabric_statistics/iscsi_sess_stats/conn_digest_errors"), "5\n") + stats := "target/core/iblock_0/lv1/statistics" + writeMetricFile(t, fs.ConfigFS, path.Join(stats, "scsi_lu/num_cmds"), "42\n") + writeMetricFile(t, fs.ConfigFS, path.Join(stats, "scsi_lu/read_mbytes"), "3\n") + writeMetricFile(t, fs.ConfigFS, path.Join(stats, "scsi_lu/write_mbytes"), "4\n") + writeMetricFile(t, fs.ConfigFS, path.Join(stats, "scsi_lu/resets"), "6\n") + writeMetricFile(t, fs.ConfigFS, path.Join(stats, "scsi_lu/status"), "available\n") + writeMetricFile(t, fs.ConfigFS, path.Join(stats, "scsi_lu/state_bit"), "exposed\n") + writeMetricFile(t, fs.ConfigFS, path.Join(stats, "scsi_tgt_dev/aborts_complete"), "7\n") + writeMetricFile(t, fs.ConfigFS, path.Join(stats, "scsi_tgt_dev/aborts_no_task"), "8\n") + writeMetricFile(t, fs.ConfigFS, path.Join(stats, "scsi_tgt_dev/resets"), "9\n") + lunStats := path.Join(targetPath, "tpgt_1/lun/lun_0/statistics/scsi_tgt_port") + writeMetricFile(t, fs.ConfigFS, path.Join(lunStats, "busy_count"), "10\n") + writeMetricFile(t, fs.ConfigFS, path.Join(lunStats, "in_cmds"), "11\n") + writeMetricFile(t, fs.ConfigFS, path.Join(lunStats, "hs_in_cmds"), "12\n") + + metrics, err := adapter.Metrics(context.Background()) + if err != nil { + t.Fatalf("Metrics: %v", err) + } + if len(metrics.Targets) != 1 { + t.Fatalf("targets = %#v", metrics.Targets) + } + target := metrics.Targets[0] + if target.Target != spec.TargetName || target.Sessions != 1 || target.Nodes != 1 || target.Portals != 1 { + t.Fatalf("target = %#v", target) + } + if target.Login.Accepts != 3 || target.Login.AuthenticateFails != 1 || + target.Login.AuthorizeFails != 2 || target.Login.NegotiateFails != 3 || + target.Login.OtherFails != 4 || target.Login.TargetLoginFails != 10 { + t.Fatalf("login = %#v", target.Login) + } + if target.Logout.Abnormal != 2 || target.SessionErrors.FormatErrors != 1 || + target.SessionErrors.ConnectionErrors != 2 || target.SessionErrors.DigestErrors != 3 { + t.Fatalf("target errors = %#v %#v", target.Logout, target.SessionErrors) + } + if len(target.Initiators) != 1 { + t.Fatalf("initiators = %#v", target.Initiators) + } + initiator := target.Initiators[0] + if initiator.Initiator != spec.Initiator || initiator.RXBytes != 12582912 || + initiator.TXBytes != 1048576 || initiator.CommandPDUs != 42 || + initiator.ResponsePDUs != 40 || initiator.ConnectionTimeoutErrors != 4 || + initiator.ConnectionDigestErrors != 5 { + t.Fatalf("initiator = %#v", initiator) + } + if len(target.LUNs) != 1 { + t.Fatalf("luns = %#v", target.LUNs) + } + lun := target.LUNs[0] + if lun.LUN != 0 || lun.ResourceUUID != spec.ResourceUUID || lun.Commands != 42 || + lun.ReadBytes != 3*1024*1024 || lun.WriteBytes != 4*1024*1024 || + lun.Resets != 15 || lun.AbortsComplete != 7 || lun.AbortsNoTask != 8 || + lun.Status != "available" || lun.StateBit != "exposed" || + lun.BusyCount != 10 || lun.InCommands != 11 || lun.HighPriorityInCommands != 12 { + t.Fatalf("lun = %#v", lun) + } +} + +func TestISCSIMetricsEmptyAndContext(t *testing.T) { + adapter, _ := newTestISCSI(t) + metrics, err := adapter.Metrics(context.Background()) + if err != nil || len(metrics.Targets) != 0 { + t.Fatalf("empty metrics = %#v, %v", metrics, err) + } + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := adapter.Metrics(cancelled); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled metrics = %v", err) + } +} + +func TestISCSIMetricsReportsBadTargetStats(t *testing.T) { + adapter, fs := newTestISCSI(t) + spec := testSpec() + if _, err := adapter.Export(context.Background(), spec, testCredential()); err != nil { + t.Fatalf("Export: %v", err) + } + targetPath := path.Join("target/iscsi", spec.TargetName) + writeMetricFile(t, fs.ConfigFS, path.Join(targetPath, "fabric_statistics/iscsi_instance/sessions"), "not-a-number\n") + writeMetricFile(t, fs.ConfigFS, path.Join(targetPath, "fabric_statistics/iscsi_instance/nodes"), "1\n") + writeMetricFile(t, fs.ConfigFS, path.Join(targetPath, "fabric_statistics/iscsi_instance/portals"), "1\n") + metrics, err := adapter.Metrics(context.Background()) + if err != nil { + t.Fatalf("Metrics: %v", err) + } + if len(metrics.Targets) != 1 || metrics.Targets[0].Error == "" { + t.Fatalf("bad metrics = %#v", metrics) + } +} + +func TestISCSIClientConnectionsAndProcParsing(t *testing.T) { + adapter, fs := newTestISCSI(t) + adapter.procNetDir = t.TempDir() + spec := testSpec() + if _, err := adapter.Export(context.Background(), spec, testCredential()); err != nil { + t.Fatalf("Export: %v", err) + } + aclPath := path.Join("target/iscsi", spec.TargetName, "tpgt_1/acls", spec.Initiator) + writeMetricFile(t, fs.ConfigFS, path.Join(aclPath, "info"), + "InitiatorName: "+spec.Initiator+"\n Address 192.0.2.10 TCP StatSN: 0x1\n") + writeMetricFile(t, fs.ConfigFS, "target/iscsi/"+spec.TargetName+"/tpgt_1/acls/other/info", + "InitiatorName: other\n Address 192.0.2.11 TCP StatSN: 0x1\n") + procData := " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n" + + " 0: 0100007F:0CBC 0A0200C0:9C40 01 00000000:00000000 00:00000000 00000000 0 0 1\n" + + " 1: 0100007F:270F 0A0200C0:9C41 01 00000000:00000000 00:00000000 00000000 0 0 2\n" + + " 2: 0100007F:0CBC 0A0200C0:9C42 06 00000000:00000000 00:00000000 00000000 0 0 3\n" + if err := os.WriteFile(path.Join(adapter.procNetDir, "tcp"), []byte(procData), 0o600); err != nil { + t.Fatal(err) + } + + metrics, err := adapter.Metrics(context.Background()) + if err != nil { + t.Fatalf("Metrics: %v", err) + } + if metrics.ConnectionError != "" { + t.Fatalf("connection error: %s", metrics.ConnectionError) + } + if len(metrics.Connections) != 1 { + t.Fatalf("connections = %#v", metrics.Connections) + } + connection := metrics.Connections[0] + if connection.ClientAddress != "192.0.2.10" || connection.ClientPort != 40000 || + connection.LocalAddress != "127.0.0.1" || connection.LocalPort != 3260 || + connection.Target != spec.TargetName || connection.Initiator != spec.Initiator || + connection.Ambiguous { + t.Fatalf("connection = %#v", connection) + } + + clientAddress, port, err := parseProcEndpoint("0A0200C0:9C40") + if err != nil || clientAddress != "192.0.2.10" || port != 40000 { + t.Fatalf("endpoint = %q %d %v", clientAddress, port, err) + } + ipv6, err := parseProcAddress("00000000000000000000000000000000") + if err != nil || ipv6 != "::" { + t.Fatalf("IPv6 = %q %v", ipv6, err) + } + if _, err := parseProcAddress("bad"); err == nil { + t.Fatal("invalid address accepted") + } + if _, _, err := parseProcEndpoint("bad"); err == nil { + t.Fatal("invalid endpoint accepted") + } +} + +func TestLUNIndexAndResourceFilter(t *testing.T) { + if index, err := parseLUNIndex("lun_12"); err != nil || index != 12 { + t.Fatalf("lun index = %d, %v", index, err) + } + if _, err := parseLUNIndex("not-a-lun"); err == nil { + t.Fatal("invalid LUN index accepted") + } + if mbytes(-1) != 0 || mbytes(2) != 2*1024*1024 { + t.Fatal("mbytes conversion failed") + } +} diff --git a/web/.nvmrc b/web/.nvmrc new file mode 100644 index 0000000..1d9b783 --- /dev/null +++ b/web/.nvmrc @@ -0,0 +1 @@ +22.12.0 diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..aef5396 --- /dev/null +++ b/web/README.md @@ -0,0 +1,13 @@ +# kouja admin UI + +Preact + TypeScript + Vite frontend for the local kouja admin listener. + +```sh +npm ci +npm run dev # proxy /api to http://127.0.0.1:9444 +npm run build # typecheck and build into ../internal/adminui/assets +npm run typecheck +``` + +`make build` builds this UI and then the Go binary. `make build-go` builds the +Go binary without Node and serves the fallback page. diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..7ccfa8b --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + kouja admin + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..3461a2a --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2176 @@ +{ + "name": "kouja-admin-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kouja-admin-ui", + "version": "0.1.0", + "dependencies": { + "preact": "11.2.0" + }, + "devDependencies": { + "@preact/preset-vite": "2.10.6", + "typescript": "7.0.2", + "vite": "8.3.6" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.13.tgz", + "integrity": "sha512-j1nvOQCZYSgp7HLmisnCXFTpVvFODiijQ9QHkORfGKWH6YDtskTEeCJS7eV545GZ9UBzrq0e2xsOd5QQ5PjIqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.13", + "@babel/helper-compilation-targets": "^7.29.13", + "@babel/helper-module-transforms": "^7.29.13", + "@babel/helpers": "^7.29.13", + "@babel/parser": "^7.29.13", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.13", + "@babel/types": "^7.29.11", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.13.tgz", + "integrity": "sha512-T5FRo+RzVHvGjWOuF1Ljc44b+NXAOazodHkGUHp4I3zCkQ4/sNVJm56h1S278LMR0bY5xBhztGziSbli8zObIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.13", + "@babel/types": "^7.29.11", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.13.tgz", + "integrity": "sha512-8WxJ4/Vmes3ghacj8VOJy3NW8eyHiFY/4thU/LSBuBE470+igjR/j1ZTNn8fV5VdNrLx6iXUwig526UdOvfP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.13.tgz", + "integrity": "sha512-/U96wOz+SCoZ7Da/8kjDGltcNY280JPQcRNPK0Hk3PTbdpaiBDQImtC6JsbVwb1HGMr3usZQU2WsWVjhYNmRzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.13" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.13.tgz", + "integrity": "sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.13.tgz", + "integrity": "sha512-laoRpD/kO/iA4CDlgJbixHekg7oPvWALK0PQyK3Ob2I2njZRvVAD77RfWCIImtqWCq6KijoBoZK7bp/HlTWASQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.11" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.13.tgz", + "integrity": "sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.13.tgz", + "integrity": "sha512-0ve0xMlFA73dgY1oyJRY6RoW+/bncdcOQqg6jjYcVfXBK90OdLXjm9R6lmRetdZVrPjXkKIoMVBqYFPjcAlZ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.13", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.13", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.11", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@preact/preset-vite": { + "version": "2.10.6", + "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.6.tgz", + "integrity": "sha512-ZZ5RIT2ZVXg8UxRA8VZpoT8CdpDG7RFIqOT4bELqNBp85+HKvQBbgmh3gsoW1UOQXx26TLe+ZRNKI7q281Kckw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@prefresh/vite": "^2.4.11", + "@rollup/pluginutils": "^5.0.0", + "babel-plugin-transform-hook-names": "^1.0.2", + "debug": "^4.4.3", + "magic-string": "^0.30.21", + "picocolors": "^1.1.1", + "vite-prerender-plugin": "^0.5.8", + "zimmerframe": "^1.1.4" + }, + "peerDependencies": { + "@babel/core": "7.x", + "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/@prefresh/babel-plugin": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.4.tgz", + "integrity": "sha512-GSwqHGVem7AjnOKAkmLNbAExqZZFddf9S3ySOwowGEsJR8vwNKJ7Z/262fov5wra8LkDVSmFw3CjMGRdLoCmRA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@prefresh/core": { + "version": "1.5.11", + "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.11.tgz", + "integrity": "sha512-Ml00PP8jeHaADxfQqxUs4+M5JS+T5J26UnvPVfsrmlpGw2I3e9XtM0DMeuzkmvg+Tn7VdHSMpLBrDPG0iHoDqQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "preact": "^10.0.0 || ^11.0.0-0" + } + }, + "node_modules/@prefresh/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/vite": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.12.tgz", + "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.22.1", + "@prefresh/babel-plugin": "^0.5.2", + "@prefresh/core": "^1.5.0", + "@prefresh/utils": "^1.2.0", + "@rollup/pluginutils": "^4.2.1" + }, + "peerDependencies": { + "preact": "^10.4.0 || ^11.0.0-0", + "vite": ">=2.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.5.1.tgz", + "integrity": "sha512-P9ZvnfouRu4MSC+ilERaw6FmZs8zqKhpHxEs4aGDGWQAMzQGXnhvCSvoNwokfNvdt7t3Y5Y4p6gStQgezeIqug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/babel-plugin-transform-hook-names": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", + "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.12.10" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.35.tgz", + "integrity": "sha512-GNk8KX52CHqeNKGk8+7tZnQab/2k1t/BgrUHPKNdijzpEhF1saPQZHVKb6m5loSAxB39gyrTYw34Qf9CQ3u1qQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/browserslist": { + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", + "update-browserslist-db": "^1.3.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001823", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001823.tgz", + "integrity": "sha512-TQ5s/djDjNk1axreUW+4rULH+BmdCMLiJkhaXnO5wqivG7tITkSUj4ItIdzqdZHKaXcHAV8dhH3JxOM7/7bwqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.478", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.478.tgz", + "integrity": "sha512-pftG+ma5Lm8O6/p45bhYT6StBN5ziQymaRzmCZwFl8DwWHKPPD93FdOmP1aqftcepolQYvrcyTLa17kE8Yf+jg==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.63", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.63.tgz", + "integrity": "sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-11.2.0.tgz", + "integrity": "sha512-/HfHI/7N84e6GrWBJ3J280z3mLaRJiDcSvj/A+y/4AbLnND/1UlGHe1sR6oYuTGwId0nP7dqDU9OnLxMPZ7xBg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=6.7.0" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/rolldown": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.156.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" + } + }, + "node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/simple-code-frame": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/simple-code-frame/-/simple-code-frame-1.3.0.tgz", + "integrity": "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.6.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-trace": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0.tgz", + "integrity": "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.6.tgz", + "integrity": "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.32", + "rolldown": "~1.2.16", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-prerender-plugin": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.14.tgz", + "integrity": "sha512-X2w9AS1OhwEUec9HSryGAmEnEQJGhW6eFoiCVJv8BfOTVpM8SOjk75cEw/IC28APDHZudeIRbP8Sp7wfABq+Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.8.0", + "magic-string": "0.x >= 0.26.0", + "node-html-parser": "^6.1.12", + "simple-code-frame": "^1.3.0", + "source-map": "^0.7.4", + "stack-trace": "^1.0.0-pre2" + }, + "peerDependencies": { + "vite": "5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..30a16f6 --- /dev/null +++ b/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "kouja-admin-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "preact": "11.2.0" + }, + "devDependencies": { + "@preact/preset-vite": "2.10.6", + "typescript": "7.0.2", + "vite": "8.3.6" + } +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..8599acb --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,122 @@ +import { + ApiError, + type CertificatesResponse, + type ClassListResponse, + type ConfigResponse, + type RegisterVGResponse, + type SaveConfigResponse, + type SetClassResponse, + type StatusResponse, + type VGListResponse, +} from "./types"; + +async function parseResponse(response: Response): Promise { + if (response.ok) { + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; + } + let code = "http_error"; + let message = `${response.status} ${response.statusText}`; + try { + const body = (await response.json()) as { error?: string; code?: string }; + code = body.code ?? code; + message = body.error ?? message; + } catch { + // Keep the HTTP status text when the body is not JSON. + } + throw new ApiError(response.status, code, message); +} + +export async function getStatus(signal?: AbortSignal): Promise { + const response = await fetch("/api/status", { signal, headers: { Accept: "application/json" } }); + return parseResponse(response); +} + +export async function getConfig(signal?: AbortSignal): Promise { + const response = await fetch("/api/config", { signal, headers: { Accept: "application/json" } }); + return parseResponse(response); +} + +export async function saveConfig(yaml: string, etag: string): Promise { + const headers: Record = { "Content-Type": "application/yaml" }; + if (etag === "*") { + headers["If-None-Match"] = "*"; + } else { + headers["If-Match"] = etag; + } + const response = await fetch("/api/config", { method: "PUT", headers, body: yaml }); + return parseResponse(response); +} + +export async function getVGs(): Promise { + const response = await fetch("/api/vgs", { headers: { Accept: "application/json" } }); + return parseResponse(response); +} + +export async function registerVG(name: string): Promise { + const response = await fetch("/api/vgs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + return parseResponse(response); +} + +export async function deregisterVG(name: string): Promise { + const response = await fetch(`/api/vgs/${encodeURIComponent(name)}`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + }); + await parseResponse(response); +} + +export async function getClasses(): Promise { + const response = await fetch("/api/classes", { headers: { Accept: "application/json" } }); + return parseResponse(response); +} + +export async function setClass(name: string, vg: string): Promise { + const response = await fetch(`/api/classes/${encodeURIComponent(name)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ vg }), + }); + return parseResponse(response); +} + +export async function removeClass(name: string): Promise { + const response = await fetch(`/api/classes/${encodeURIComponent(name)}`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + }); + await parseResponse(response); +} + +export async function disableISCSITarget(owner: string, volumeID: string): Promise { + const response = await fetch("/api/targets/disable", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ owner, volume_id: volumeID }), + }); + await parseResponse(response); +} + +export async function enableISCSITarget(owner: string, volumeID: string): Promise { + const response = await fetch("/api/targets/enable", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ owner, volume_id: volumeID }), + }); + await parseResponse(response); +} + +export async function getCertificates(): Promise { + const response = await fetch("/api/certificates", { headers: { Accept: "application/json" } }); + return parseResponse(response); +} + +export function certificateDownloadURL(name: string): string { + return `/api/certificates/${encodeURIComponent(name)}`; +} diff --git a/web/src/app.tsx b/web/src/app.tsx new file mode 100644 index 0000000..25f4b1b --- /dev/null +++ b/web/src/app.tsx @@ -0,0 +1,151 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { getClasses, getConfig, getStatus, getVGs } from "./api"; +import { ConfigPage } from "./components/ConfigPage"; +import { StatusPage } from "./components/StatusPage"; +import type { + ConfigResponse, + SaveConfigResponse, + StatusResponse, + VGStatus, +} from "./types"; + +type Tab = "status" | "config"; + +export function App() { + const [tab, setTab] = useState("status"); + const [status, setStatus] = useState(null); + const [config, setConfig] = useState(null); + const [vgs, setVGs] = useState([]); + const [classes, setClasses] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [configError, setConfigError] = useState(""); + const [configDirty, setConfigDirty] = useState(false); + const abortRef = useRef(null); + + async function refreshStatus() { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setLoading(true); + try { + const next = await getStatus(controller.signal); + setStatus(next); + setError(""); + } catch (caught) { + if (caught instanceof DOMException && caught.name === "AbortError") { + return; + } + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + if (abortRef.current === controller) { + setLoading(false); + } + } + } + + async function refreshConfig() { + try { + const next = await getConfig(); + setConfig(next); + setConfigError(""); + } catch (caught) { + setConfigError(caught instanceof Error ? caught.message : String(caught)); + } + } + + async function refreshVGs() { + try { + const [nextVGs, nextClasses] = await Promise.all([getVGs(), getClasses()]); + setVGs(nextVGs.vgs ?? []); + setClasses(nextClasses.classes ?? {}); + } catch (caught) { + setConfigError(caught instanceof Error ? caught.message : String(caught)); + } + } + + async function refreshAll() { + await Promise.all([refreshStatus(), refreshConfig(), refreshVGs()]); + } + + useEffect(() => { + void refreshAll(); + const timer = window.setInterval(() => { + if (document.visibilityState === "visible") { + void refreshStatus(); + } + }, 5000); + return () => { + window.clearInterval(timer); + abortRef.current?.abort(); + }; + }, []); + + useEffect(() => { + if (tab === "config") { + void refreshVGs(); + } + }, [tab]); + + const onSaved = (_result: SaveConfigResponse) => { + void refreshStatus(); + }; + + return ( +
+
+
+

kouja admin

+

Local-only configuration and status. No authentication.

+
+ +
+
+ {tab === "status" && ( + void refreshStatus()} + /> + )} + {tab === "config" && + (config ? ( + + ) : ( +
+

Configuration

+

+ {configError || "Loading configuration…"} +

+ +
+ ))} +
+
+ ); +} diff --git a/web/src/components/CertificatesPanel.tsx b/web/src/components/CertificatesPanel.tsx new file mode 100644 index 0000000..1c3ac2e --- /dev/null +++ b/web/src/components/CertificatesPanel.tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from "preact/hooks"; +import { certificateDownloadURL, getCertificates } from "../api"; +import { formatBytes, formatTime } from "../format"; +import type { CertificatesResponse } from "../types"; + +export function CertificatesPanel() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function refresh() { + setLoading(true); + setError(""); + try { + setData(await getCertificates()); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void refresh(); + }, []); + + const files = data?.files ?? []; + const hasPrivateKey = files.some((file) => file.kind === "private_key"); + + return ( +
+
+

Client certificates

+ +
+

+ Files are read from {data?.directory || "the configured client certificate directory"}. + Only regular .crt, .key, and .pem files in that + directory are served. The CA private key and server private key are never exposed here. +

+ {data?.ca ? ( +
+ + Download client CA + + {data.ca.name} +
+ ) : ( +

The client CA certificate is not configured or not readable.

+ )} + {hasPrivateKey && ( +

+ This list includes private keys. Downloading them gives the holder the same client + identity as the certificate. Keep the files protected. +

+ )} + {error &&

{error}

} + {data?.error &&

{data.error}

} +
+ + + + + + + + + + + + {files.length === 0 && ( + + + + )} + {files.map((file) => ( + + + + + + + + + ))} + +
FileKindRoleSizeModified +
+ No client certificate files found. +
{file.name}{file.kind === "private_key" ? "private key" : "certificate"}{file.role || "unknown"}{formatBytes(file.size_bytes)}{formatTime(file.modified_at)} + + Download + +
+
+
+ ); +} diff --git a/web/src/components/ConfigPage.tsx b/web/src/components/ConfigPage.tsx new file mode 100644 index 0000000..78bf79a --- /dev/null +++ b/web/src/components/ConfigPage.tsx @@ -0,0 +1,212 @@ +import { useEffect, useState } from "preact/hooks"; +import { + deregisterVG, + registerVG, + removeClass, + saveConfig, + setClass, +} from "../api"; +import { ApiError, type ConfigPageProps, type WarningView } from "../types"; +import { CertificatesPanel } from "./CertificatesPanel"; +import { VgsPanel } from "./VgsPanel"; + +export function ConfigPage({ + config, + vgs, + classes, + onSaved, + onDirtyChange, + onRefreshVGs, + onReloadConfig, +}: ConfigPageProps) { + const [yaml, setYaml] = useState(config.disk_yaml); + const [etag, setEtag] = useState(config.disk_etag); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [warnings, setWarnings] = useState([]); + const [notice, setNotice] = useState(""); + + useEffect(() => { + if (!dirty) { + setYaml(config.disk_yaml); + setEtag(config.disk_etag); + } + }, [config.disk_yaml, config.disk_etag, dirty]); + + useEffect(() => { + onDirtyChange(dirty); + }, [dirty, onDirtyChange]); + + useEffect(() => { + if (!dirty) { + return; + } + const handler = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [dirty]); + + function updateYaml(value: string) { + setYaml(value); + setDirty(true); + setNotice(""); + } + + async function save() { + setSaving(true); + setError(""); + setWarnings([]); + setNotice(""); + try { + const result = await saveConfig(yaml, etag); + setEtag(result.disk_etag); + setDirty(false); + setWarnings(result.warnings); + setNotice( + result.restart_required + ? `Saved. Binding socket changed (${result.restart_reason}); restart the service manually.` + : "Saved. No binding socket change.", + ); + onSaved(result); + await onReloadConfig(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + if (caught instanceof ApiError && caught.status === 409) { + await onReloadConfig(); + } + } finally { + setSaving(false); + } + } + + const saveDisabled = + saving || + !dirty || + config.disk_error_code === "unsafe_path" || + config.disk_error_code === "read_error"; + + return ( +
+ {(config.config_changed || config.restart_required) && ( +
+ {config.restart_required + ? `Binding socket changed (${config.restart_reason}); restart the service manually.` + : "The disk configuration differs from the effective running configuration."} +
+ )} +
+

TLS server certificate

+ {config.tls.error ? ( +

{config.tls.error}

+ ) : ( +

+ Clients must connect using one of these names or addresses and trust the + client CA. Use the exact value expected by the client’s TLS server-name + verification. +

+ )} +
+
+ Subject + {config.tls.subject || "unknown"} +
+
+ Expires + {config.tls.not_after || "unknown"} +
+
+ TLS server name(s) + + {config.tls.server_names.length > 0 ? config.tls.server_names.join(", ") : "none"} + +
+
+ IP addresses + + {config.tls.ip_addresses.length > 0 ? config.tls.ip_addresses.join(", ") : "none"} + +
+
+
+
+
+

Configuration

+ {config.path} +
+ {config.last_good_error &&

Last-good: {config.last_good_error}

} + {config.disk_error && ( +

+ {config.disk_error_code}: {config.disk_error} +

+ )} + {notice &&

{notice}

} + {error &&

{error}

} + {warnings.map((warning) => ( +

+ {warning.code}: {warning.message} +

+ ))} + +