pegaprox-plugin-truenas

PegaProx plugin: monitor and control TrueNAS SCALE instances via WebSocket JSON-RPC 2.0 — TrueCommand-style, multi-tenant, dataset/snapshot writes with typed confirmation and audit trail.

This is a community plugin. PegaProx didn't write it, doesn't audit it, and isn't responsible for what it does to your cluster. Read the source on GitHub before installing — and re-read every diff after each update. Full disclaimer →

TrueNAS — PegaProx Plugin

Monitors and administers one or more TrueNAS SCALE instances from the PegaProx panel (TrueCommand-style), over the JSON-RPC 2.0 WebSocket API — REST v2.0 is deprecated in 25.10 and removed in TrueNAS 26, so this plugin never uses it.

v0.9.0 — enough surface to run TrueNAS day-to-day without opening its own UI: pools/disks health, datasets, snapshots, SMB/NFS shares (iSCSI is read-only), replication, apps/VMs, services, data-protection posture (cloudsync/rsync/certificates) and CPU/memory/network telemetry. Every write (datasets/zvols, snapshots, services, VMs/apps, SMB/NFS shares) goes through the same dry-run → typed-confirmation → execute → verify → audit path — see WRITE_OPS in src/routes/api.py. 352 tests, all built and verified against fakes/mocks first; every real RPC schema was confirmed live against a real instance (core.get_methods) before being coded, and every write phase's first real call was reviewed by the operator before shipping. See CHANGELOG.md for the full phase-by-phase history (F0 transport/config → F1 read-only monitoring → F2 datasets/snapshots writes → F3 fleet overview → F4a-c services/shares → F5 VMs/apps lifecycle → F6 data-protection posture → telemetry charts).

Feature phases (F0 → F6)

Full detail for every entry below is in CHANGELOG.md; this is the map.

F3 — Fleet overview

src/subsystems/fleet.py fans out across every configured instance concurrently (ThreadPoolExecutor) and aggregates health + recent non-auth audit events into one dashboard — the plugin's entry tab.

F4a/F4b — Services (read-only, then start/stop/restart)

src/subsystems/services.py. F4b's control ops needed a SERVICE_WRITE privilege grant on the RW service account before the RPCs became visible — same pattern repeated for F5's VM_WRITE/APPS_WRITE.

F4c — SMB/NFS share create/update/delete

src/subsystems/shares.py. Typed-confirmation delete guard adapted for opaque integer share ids (the caller supplies expected_name/ expected_path instead of the id being self-referential like a dataset path). iSCSI stays read-only — see "Known gaps" below.

F5 — VM/App lifecycle control

src/subsystems/apps_vms.py: start/stop/restart for VMs, start/stop/ redeploy for Apps (app.restart doesn't exist on this TrueNAS version — redeploy pulls fresh images, a heavier op, never mislabeled as a plain restart).

F6 — Data-protection posture

src/subsystems/data_protection.py — Cloudsync/Rsync tasks + certificate expiry, read-only. Security fix baked in: this is the one subsystem that does NOT do this codebase's usual attrs-passthrough, because the raw cloudsync.query/certificate.query records embed cleartext secrets (a cloud provider's API key, a certificate's private key PEM). Every function returns an explicit field allow-list instead; tests assert the secret strings are ABSENT from the output, not just that safe fields are present.

Telemetry — CPU/memory/network sparklines

src/subsystems/telemetry.py, backed by reporting.get_data. No charting library (CT119 has no internet access to fetch one) — hand-rolled SVG <polyline> sparklines, server-side downsampled to ≤120 points.

F2 — datasets/snapshots writes (on top of F1)

  • Write path (src/subsystems/datasets.py, snapshots.py): create/update/delete (datasets) and create/delete (snapshots), each split into a pure build_<op>_envelope(...) (no conn — returns (method, params) or raises) and a real op that calls that SAME builder. POST writes/dry-run / POST writes/execute (both admin-gated) share this registry (WRITE_OPS in src/routes/api.py) so a dry-run preview can never describe a different call than what actually runs.
  • Typed confirmation on delete: confirm_name must match the resource's full name/id exactly, checked inside the builder — before any envelope exists, let alone before any TrueNAS call.
  • ConnectionManager.get_rw_connection(): separately cached from the read-only client — a write never upgrades the shared read connection's privilege.
  • _resolve_writable_instance: readonly is False + api_key_rw present, both required before anything else happens.
  • Post-write verify + audit, every outcome (ok/pending/ verify_failed/error) logged, never auto-retried.
  • See CHANGELOG.md [0.3.0] for the full write-flow detail and the sync-vs-async design decision.

What's in F1 (on top of F0)

  • src/core/subsystem.py — the Subsystem contract (list/read/ health, write() read-only by default) every module in src/subsystems/ implements.
  • src/subsystems/{system,pools,datasets,snapshots,shares,replication, apps_vms}.py — one module per TrueNAS concept, each wrapping the exact JSON-RPC methods from brief §4.2. See CHANGELOG.md [0.2.0] for the full per-module method list and the pools temperature-exclusion safety correction (brief §4.3/§9).
  • 7 new read routes in src/routes/api.py, instance_id as a query param (see the module docstring for why — the confirmed plugin routing mechanism doesn't support URL path parameters).
  • TrueNASWSClient.is_authenticated — tracks the CURRENT socket's live session, distinct from "an api_key is remembered for relogin".
  • src/ui/plugin.html — every tab fetches and renders real data now.

What's in F0

  • src/core/ws_client.py — a generic, reusable JSON-RPC 2.0 client over a persistent WebSocket: request/response framing, concurrent id handling, typed errors, lazy-connect, and automatic reconnection (exponential backoff + jitter) that re-logs-in and re-subscribes after a drop.
  • src/core/conn_manager.py — one client per configured instance, lazy-connect, multi-instance from day one.
  • config.example.json / config.json (chmod 600, never committed) — multi-tenant instance list (client_id + host/port/TLS/API keys/readonly) and the polling budget.
  • src/routes/api.py + src/routes/config_store.py — the ui, config, config/save and instances/test routes, with masked-key round-tripping.
  • src/ui/plugin.html — the UI shell: instance selector grouped by client, placeholder tabs, and a functional Settings tab.

Multi-tenant (brief §3.1)

This plugin will eventually manage TrueNAS instances belonging to different clients in the same panel (e.g. acme, globex, initech, ...), not just the operator's own infrastructure. Every instance in config.json carries a free-form client_id field from F0 onward — the Settings UI and the instance selector group by it. F0 does not yet call PegaProx's check_cluster_access for per-client scoping — that mapping (client_id ↔ Proxmox cluster) lands in F1+, once more than one real client is on-boarded. Until then, every write in this plugin (config, instance test) is gated on the admin role, same as the rest of the plugin's RBAC.

RBAC

PegaProx's PERMISSIONS table is fixed — a plugin cannot register new assignable permissions. This plugin reuses:

Action Permission
ui (the tab itself) storage.view
system, pools, datasets, snapshots, shares, replication, apps_vms (F1 reads) storage.view
config, config/save, instances/test (touch API keys) admin role only

admin always passes any has_permission check automatically.

Configuration

Each entry in instances[]:

Field Meaning
id Stable identifier, unique within this plugin's config
name Display name
client_id Free-form tenant namespace (idkmanager, acme, ...)
host / port TrueNAS UI host and port
use_tls Must be true whenever an API key is set — TrueNAS auto-revokes a key used over plain HTTP. config/save rejects the combination.
verify_tls Whether to verify the appliance's TLS certificate (usually self-signed → false)
api_key_ro / api_key_rw Service-account keys (svc-pegaprox-ro/svc-pegaprox-rw); masked as *** on every GET config, round-tripped on save
readonly Server-evaluated kill-switch — stays effective even if api_key_rw is set

poll.{fast_s,slow_s,cold_s} — the polling budget from the brief §4.3; unused in F0 (no subsystem polls anything yet), validated and persisted for F1 to consume.

instances/test

Connects the WebSocket and calls auth.login_with_api_key — nothing else (no subsystem call). Used from the Settings tab's "Probar conexión" button, works against either a saved instance (id) or an unsaved draft from the form, and never persists anything to config.json. (F0 called this "the only real TrueNAS interaction allowed" — F1 adds seven read-only ones on top, see above.)

Design decisions taken where the brief was ambiguous

  • Reconnect trigger, not a background poller. The client's automatic reconnection only runs after the WebSocket reader thread observes an unexpected disconnect (a recv() failure) — there is no separate keepalive/health-check thread pinging the socket. This satisfies "backoff
    • jitter reconnection" without adding a second timer to reason about in F0; F1's job-tracking loop can add liveness probing if TrueNAS's own socket timeout proves too silent in practice.
  • Relogin/resubscribe only on recovery, not on the very first connect. An earlier draft called _relogin_and_resubscribe() unconditionally after every successful connect(). That recursed: subscribe() registers its callback before issuing the core.subscribe call, so the first-ever connect (triggered lazily by that same subscribe()) would re-enter call() for the same subscription while the outer call was still in-flight. Fixed by only running relogin/resubscribe from the _background_reconnect path (a genuine post-drop recovery), never from plain lazy-connect.
  • log_audit(..., cluster=client_id) not used as written in brief §3.1. Without a live PegaProx host to confirm log_audit's real keyword arguments, passing an unverified cluster= kwarg risked a runtime TypeError in production. client_id is instead folded into the details string of every audit call — same information, no dependency on an unconfirmed signature. Revisit once pegaprox/utils/audit.py is readable from this workspace.
  • instances/test accepts drafts, not just saved instances, so the Settings "Probar conexión" button works before hitting Save (per the UI flow described in brief §6/§7) — it resolves a masked api_key_ro back to the stored value only when an id matching a saved instance is supplied.

Design decisions taken in F1 where the brief was ambiguous

  • instance_id as a query param, not a URL path segment. The brief's /<instance_id>/<subsystem> phrasing reads like a URL template, but the only routing mechanism confirmed in production (pegaprox.api.plugins.register_plugin_route, verified against pegaprox-plugin-wake-on-lan) maps one FIXED path string per handler — wake-on-lan's own dynamic routes already use query params for exactly this reason. Followed the proven pattern rather than assume PegaProx's catch-all supports path parameters it hasn't been observed to support.
  • shares.list()/apps_vms.list() return a dict, not a flat list. Both wrap multiple distinct TrueNAS collections (SMB/NFS/3× iSCSI; apps/VMs) that the UI's own tab layout (brief §6) treats as separate groups — flattening them would just force the caller to re-split what was artificially joined.
  • No virt.instance.* shim for VMs. The brief flags 25.04→25.10 moved VMs between Incus and libvirt namespaces. vm.query responds (with []) on the real .64 (25.10.1) today, so no shim is implemented — adding one now would be speculative code for a namespace not in use on the only instance this plugin talks to. Add it if/when a future instance proves vm.query errors and virt.instance.query answers instead.

Known gaps / explicitly out of scope

  • iSCSI CRUD. An iSCSI "share" is a 3-way join (target + extent + targetextent) — meaningfully bigger than SMB/NFS create/update/delete, which already covers the "share a folder without opening TrueNAS" ask. iSCSI stays read-only.
  • No job poller for the (rare) async writes — a create/delete that comes back as a TrueNAS job is reported as pending with a re-check path, not actively polled to completion.
  • No check_cluster_access per-client RBAC — admin-only gate until a second real client is on-boarded (all instances so far belong to the operator's own idkmanager tenant, not a second real client).
  • Two instances configured today, both idkmanager: datos-64 (general SMB/NFS/iSCSI storage) and nvmeof-253 (the NVMe-oF pool backing VM block storage, added 2026-07-21 — svc-pegaprox-ro/READONLY_ADMIN, read-only). A second real client tenant still requires explicit operator confirmation in a separate session, same review discipline as every write phase above.
  • smart.test.results (SMART data) was dropped, not deferred — this TrueNAS version has no smart.* method namespace at all.

Development

pip install -r requirements-dev.txt
pytest -q

tests/conftest.py stubs flask and pegaprox.* at the module level so __init__.py imports standalone in CI without a live PegaProx host. core/ tests (tests/unit/) never touch a real socket — a FakeTransport drives send/recv through an in-memory queue.

Deploy

install.sh copies __init__.py, manifest.json and src/ into /opt/PegaProx/plugins/truenas, seeds an empty config.json (instances are added afterwards from the Settings tab, never shipped in the repo), and installs a systemd timer (truenas-maintenance.sh) that restores the plugin from a cache outside /opt/PegaProx if a PegaProx upgrade ever wipes it.

uninstall.sh removes the plugin, its guard timer, and its config (including any saved API keys).

Deployment is NOT performed by this repository's automation. Running install.sh against production (CT119/pve1) requires explicit operator confirmation in a separate session — see brief §0.5.