Storage
The Storage module (id storage, AGPL-3.0-only) integrates TrueNAS SCALE and CORE appliances into FreeSDN as Fabric participants. Unlike every other module, it declares no standalone HTTP routes and no database tables. Its sole job is to own the storage Fabric surface: a health-read operation, a staged blob-write operation, and six health-transition events that the rest of the platform can wire to notifications and automation.
Live storage reads are served through the controller-scoped endpoint at GET /api/v1/controllers/{controller_id}/storage. The module itself only becomes visible when you author a Fabric Connection.
What the module does
Section titled “What the module does”| Capability | Mechanism |
|---|---|
| ZFS pool health rollup | storage.health Fabric read - aggregates across one or all org TrueNAS appliances |
| Active alert aggregation | All non-dismissed appliance alerts, classified by severity |
| Disk temperatures | Per-disk SMART polling; max_temp_c surfaced at the appliance level |
| Scrub status | scrub flattened per pool (function, state, errors, percentage, finished timestamp) |
| Pool redundancy | Derived from vdev topology: RAIDZ1/2/3, Mirror, Stripe |
| Staged blob writes | storage.store_blob Fabric write - stages a file upload for operator sign-off |
| Health-transition events | Six storage.* events emitted on state changes for Fabric wiring |
Transport: wss:// only
Section titled “Transport: wss:// only”The TrueNAS adapter uses WebSocket JSON-RPC over TLS. There is no plaintext path.
Transport selection
Section titled “Transport selection”On startup the adapter attempts the WebSocket JSON-RPC transport (/api/current, port 443). It falls back to REST (/api/v2.0) only if the endpoint is unreachable - which typically means a pre-25.04 CORE box. An authentication error on a reachable WS endpoint is surfaced directly; the adapter does not mask it with a doomed REST retry.
TLS and self-signed certificates
Section titled “TLS and self-signed certificates”All TrueNAS appliances ship self-signed certificates. The adapter defaults to verify_ssl=False (check_hostname off, CERT_NONE) so health polling works out of the box. If you have replaced the appliance certificate with one from a trusted CA, set verify_ssl=True when registering the controller - but do not set it against a self-signed cert: the TLS handshake will fail.
Adding a TrueNAS controller
Section titled “Adding a TrueNAS controller”TrueNAS uses an API key stored in FreeSDN’s Controller.password field. It is Fernet-encrypted at rest and decrypted per-request.
1. Create an API key on the appliance
Section titled “1. Create an API key on the appliance”In TrueNAS: System → API Keys → Add. The key needs read access for health monitoring. Write access (the filesystem.put job) is required only if you plan to use storage.store_blob.
On SCALE 25.x the default root account has login disabled. Create the API key under truenas_admin (or another login-enabled account). An API key tied to an OTP/2FA account cannot authenticate without an OTP token - use a non-MFA service account.
2. Register the controller
Section titled “2. Register the controller”Open Settings → Controllers → Add Controller and select vendor TrueNAS. Paste the API key into the Password / API Key field. The host must be the appliance hostname or IP - FreeSDN constructs wss://{host}:443/api/current internally.
Alternatively via the API:
POST /api/v1/controllersContent-Type: application/jsonAuthorization: Bearer <token>
{ "name": "truenas-s4", "controller_type": "truenas", "host": "192.168.1.100", "port": 443, "username": "truenas_admin", "password": "<truenas-api-key>", "site_id": "<site-uuid>"}username is the TrueNAS account that owns the API key - typically truenas_admin on SCALE 25.x (where root login is disabled by default). The ControllerCreate schema validates that username is present for all non-cloud, non-Proxmox controller types; omitting it returns a 422 "Local mode requires username" error.
port is required - omitting it returns a 422 validation error. Use 443 for the standard TrueNAS HTTPS listener. If your appliance listens on a non-standard TLS port, pass that port number instead. If you supply port 80 or 0, the WS client internally maps it to 443 to prevent a plaintext connection, but you should supply the real port explicitly.
3. Verify connectivity
Section titled “3. Verify connectivity”After saving, trigger a test from the controller list or call:
POST /api/v1/controllers/{controller_id}/testA successful probe returns {"success": true, "message": "Connection successful", "status": "connected", "details": {"latency_ms": 42, "mode": "local"}}. An authentication failure returns a 502 with a hint - not a 401 - so that a revoked appliance key does not log the FreeSDN user out. (The transport field - "ws" or "rest" - is present in the storage inventory response at GET /controllers/{id}/storage, not in the test response.)
Reading storage health
Section titled “Reading storage health”Via the controller endpoint
Section titled “Via the controller endpoint”GET /api/v1/controllers/{controller_id}/storageAuthorization: Bearer <token>One round-trip aggregates system info, pools, disks, datasets, snapshots, alerts, disk temperatures, services, and data-protection tasks. The response shape:
{ "controller_id": "...", "name": "truenas-s4", "host": "192.168.1.100", "transport": "ws", "system": { "version": "TrueNAS-SCALE-24.10.2", "hostname": "truenas", "product": "TRUENAS-MINI-R", "serial": "A1B2C3D4", "physmem": 137438953472, "uptime_seconds": 1209600, "timezone": "America/New_York" }, "health": { "status": "ok", "pool_count": 3, "alert_count": 1, "critical_alert_count": 0 }, "alerts": [...], "services": [...], "data_protection": { "snapshot_tasks": [...], "replication": [...], "cloudsync": [...] }, "pools": [...], "disks": [...], "datasets": [...], "snapshot_count": 42}Health status rollup rules
Section titled “Health status rollup rules”The health.status field reflects the worst condition across all pools and alerts:
| Condition | Status |
|---|---|
| All pools ONLINE, no critical alerts | ok |
| Any pool DEGRADED or UNAVAIL | warning |
| Any pool FAULTED, OFFLINE, or REMOVED | error |
| A CRITICAL or ALERT active alert present (even if all pools are ONLINE) | error |
| A WARNING or ERROR active alert present (even if all pools are ONLINE) | warning |
Per-pool fields
Section titled “Per-pool fields”Each pool in the response includes:
| Field | Description |
|---|---|
name | ZFS pool name |
status | ONLINE / DEGRADED / FAULTED / OFFLINE / REMOVED / UNAVAIL |
usage_percent | Used percentage |
redundancy | Derived: {type, vdevs, width} (RAIDZ1/2/3, MIRROR, STRIPE) |
scrub | {function, state, errors, percentage, finished_at_ms} |
redundancy is derived by walking the pool’s vdev topology. A pool with no data vdevs (e.g. a newly created pool with only special vdevs) reports UNKNOWN ({"type": "UNKNOWN", "vdevs": 0, "width": 0}).
Per-disk fields
Section titled “Per-disk fields”Each disk in the response includes:
| Field | Description |
|---|---|
name | Device name (e.g. sda) |
type | HDD / SSD / NVMe |
size | Capacity in bytes |
serial | Drive serial number |
temperature_c | Current SMART temperature |
read_errors | ZFS read error count from pool topology |
write_errors | ZFS write error count from pool topology |
checksum_errors | ZFS checksum error count from pool topology |
pool | Name of the pool this disk belongs to (empty if a hot spare or unassigned) |
vdev_type | vdev topology type for the disk slot (RAIDZ1 / RAIDZ2 / RAIDZ3 / MIRROR / DISK; null if unassigned) |
ZFS read/write/checksum error counts are resolved by walking the pool topology tree. Temperature is merged from the SMART feed.
Fabric operations
Section titled “Fabric operations”The Storage module registers two Fabric operations in the tier-tagged catalog at GET /api/v1/fabric/catalog.
storage.health (read)
Section titled “storage.health (read)”Aggregates ZFS pool health, alert counts, and disk temperatures across the org’s TrueNAS appliances. Executes inline - no staging, no operator sign-off.
Permission required: storage.view
Input parameters:
| Parameter | Required | Description |
|---|---|---|
controller_id | No | UUID of a specific TrueNAS controller. Omit to query all org appliances. |
Response structure:
{ "status": "ok", "count": 2, "appliances": [ { "controller_id": "...", "name": "truenas-s4", "status": "ok", "pools": 3, "degraded_pools": [], "over_capacity_pools": [], "alerts": 1, "critical_alerts": 0, "max_temp_c": 42.5 }, { "controller_id": "...", "name": "truenas-offsite", "status": "unreachable", "error": "Connection refused" } ]}status is the worst-case across all queried appliances. A per-appliance connection failure is recorded as status: "unreachable" with an error message - it does not raise and does not prevent the other appliances from being queried. The top-level status escalates to error when any appliance is unreachable.
The operation is fail-closed for tenancy: only TrueNAS controllers belonging to your organization are ever queried, regardless of which controller_id you supply.
storage.store_blob (staged write)
Section titled “storage.store_blob (staged write)”Stage a file upload to a TrueNAS dataset. The blob bytes travel via ctx.input_artifact (for example, a camera snapshot forwarded by the Camera module or a Fabric Connection step). The operation is always staged - actual upload runs only after an operator applies the pending change through the dual-gate.
Permission required: storage.write
Feature flag: storage.store_blob
Input parameters:
| Parameter | Required | Max length | Description |
|---|---|---|---|
controller_id | Yes | - | UUID of the target TrueNAS controller |
dataset_path | Yes | 512 chars | Destination directory under /mnt, e.g. /mnt/tank/freesdn |
filename | Yes | 255 chars | Target filename (no path separators or ..) |
site_id | No | - | UUID for site-scoping (informational; does not filter the appliance) |
Accepted content types: image/jpeg, generic binary blob (MEDIA_BLOB).
Path validation (applied at two layers): the dataset_path must start with /mnt/, contain no .. segments, and each segment must match ^[A-Za-z0-9][A-Za-z0-9._-]*$. The filename must contain no /, \, or ... The adapter re-validates both as a backstop.
Upload mechanism: once applied, the executor decodes the blob from the artifact store, re-verifies its SHA-256, and sends it to TrueNAS via a multipart HTTPS POST to /_upload on the same TLS listener. The Authorization: Bearer <api_key> header is sent over TLS only - same revoke rule as the WS transport. Upload timeout is 300 seconds. The adapter then polls core.get_jobs at 1-second intervals until the filesystem.put job reaches SUCCESS, FAILED, or ABORTED (120-second overall timeout).
Fabric events
Section titled “Fabric events”The storage.poll_health monitoring task emits six events on state transitions, not on every poll. Wiring these to notification or automation actions is safe - they will not fire repeatedly while a pool stays degraded.
| Event type | Fires when |
|---|---|
storage.pool.degraded | A ZFS pool transitions to DEGRADED, FAULTED, OFFLINE, REMOVED, or UNAVAIL |
storage.pool.healthy | A previously non-ONLINE pool returns to ONLINE |
storage.capacity.warning | A pool crosses the 85% capacity threshold |
storage.alert.critical | A new CRITICAL or ERROR alert appears on the appliance |
storage.appliance.unreachable | The WS connection to the appliance fails |
storage.appliance.online | The appliance becomes reachable again after an unreachable period |
All events carry:
| Payload field | Description |
|---|---|
controller_id | UUID of the TrueNAS controller |
controller_name | Human-readable controller name |
pool | ZFS pool name (where applicable; empty for appliance-level events) |
capacity_pct | Pool used percentage at time of event |
critical_alerts | Count of active CRITICAL/ERROR alerts on the appliance |
Example: alert on pool degradation
Section titled “Example: alert on pool degradation”Wire storage.pool.degraded to a multi-channel notification via Fabric Connections:
{ "name": "ZFS pool degraded alert", "source": { "event_type": "storage.pool.degraded" }, "steps": [ { "operation_id": "fabric.notify", "params": { "message": "ZFS pool {{pool}} on {{controller_name}} is degraded. Critical alerts: {{critical_alerts}}", "channels": ["email", "slack"] } } ]}Example: camera snapshot to TrueNAS (staged)
Section titled “Example: camera snapshot to TrueNAS (staged)”Use a Fabric Connection to stage camera snapshots to TrueNAS storage. Because storage.store_blob is staged, an operator must sign off each time:
{ "name": "Camera snapshot to NAS (staged)", "source": { "event_type": "cameras.snapshot.captured" }, "steps": [ { "operation_id": "storage.store_blob", "params": { "controller_id": "<truenas-controller-uuid>", "dataset_path": "/mnt/tank/freesdn/snapshots", "filename": "{{camera_name}}_{{timestamp}}.jpg" } } ]}See Fabric for the full Connection authoring guide.
No standalone HTTP routes
Section titled “No standalone HTTP routes”The storage module’s get_router() returns an empty APIRouter(). There is no /api/v1/storage/ prefix. This is intentional: the module exists to own the Fabric surface, not to duplicate the controller-scoped endpoint hierarchy.
All storage REST traffic flows through:
GET /api/v1/controllers/{controller_id}/storage - requires controller:readThere are no POST, PUT, PATCH, or DELETE routes in the storage module. Configuration changes (adding, editing, or removing a TrueNAS controller) go through the standard Controllers API.
Permissions
Section titled “Permissions”| Permission code | Scope | Required for |
|---|---|---|
storage.view | Read | View storage health, pools, alerts, disk temperatures via Fabric storage.health |
storage.write | Update (staged, sign-off required) | Stage blob uploads to a TrueNAS dataset via storage.store_blob |
Reading storage data directly via GET /controllers/{id}/storage uses the controller:read permission, not storage.view. The storage module permissions apply to Fabric operation invocations.
Applying a staged storage.store_blob change requires the network:write permission. No additional role minimum (site_admin or above) is enforced - the permission gate alone governs access.
Authentication error handling
Section titled “Authentication error handling”Connection and authentication errors from TrueNAS are mapped to 502 (not 401) deliberately. A revoked or expired API key on the appliance must not cause FreeSDN to log out the FreeSDN user. The response body carries an actionable error hint:
| Condition | HTTP status | Hint |
|---|---|---|
| WS auth failure (bad key, account disabled) | 502 | Advises re-generating the TrueNAS API key |
| WS connection refused or timeout | 502 | Advises checking host/port/firewall |
| API key used over plaintext (key revoked by appliance) | 502 | Advises checking TLS configuration |
| Other | 502 | Generic storage controller communication error |
Gotchas
Section titled “Gotchas”No database tables. The storage module has no Alembic migrations and adds no PostgreSQL schema. Pool, alert, and temperature data is fetched live from the appliance on each request. There is no cached state to go stale.
SCALE 25.10/26.0 removed REST v2.0. The /api/v2.0 REST interface returns 404 on modern SCALE. If you are running current SCALE, the WS transport is the only working path. The adapter selects it automatically.
API keys auto-revoke over ws://. TrueNAS records the revocation reason in its audit log. If an appliance API key stops working, check the appliance audit log before generating a new key - a middleware terminating TLS before TrueNAS may be the root cause.
store_blob requires WS transport. The filesystem.put job is a WS JSON-RPC write. Appliances that only expose the legacy REST interface cannot accept blob writes.
store_blob is always staged. There is no force=true path that applies the write inline. The dual-gate - ADAPTER_READ_ONLY=false in the environment AND an operator applying the pending change - is unconditional for writes.
Dataset path must start with /mnt/. TrueNAS datasets always live under /mnt. Paths not starting with /mnt/ are rejected before the staged change is created.
storage.store_blob re-verifies SHA-256 before upload. If the artifact store returns bytes that do not match the stored hash, the apply step aborts. This is not a user-facing error in normal operation - it would indicate storage corruption in FreeSDN’s artifact layer.
Capacity threshold is not UI-configurable. The 85% warning threshold is defined in storage/health.py. The health read and the emitted events both derive from summarize_health(), so catalog reads and event payloads always agree.
StorageLocationsPage is a different feature. The frontend page at /storage-locations manages backup storage destinations (e.g. S3, SFTP targets used by the Backup module). It is not the TrueNAS storage area.
Next steps
Section titled “Next steps”- Fabric - Author Connections to wire storage events to notifications and automation steps.
- Configuration Backup - Use the Backup module to create portable
.fsdnconfig snapshots (separate from TrueNAS file-system backup). - Compute / Hypervisor - Manage Proxmox VE VMs and containers alongside your TrueNAS appliances from a single interface.
- Adapters: Supported Vendors - Full adapter maturity matrix showing TrueNAS depth, write surface, and known limitations.
- Controllers API - Register, edit, test, and remove controllers including TrueNAS appliances.