From f30862f3c04d8a7ac9d8b48153b0c4c2c05a942e Mon Sep 17 00:00:00 2001 From: Kevin Adams Date: Mon, 25 May 2026 22:36:21 -0400 Subject: [PATCH] feat: read API token from /etc/pve/priv/truenas-.key (#261) The API token was previously stored only in storage.cfg, which is replicated in plaintext across all cluster nodes via pmxcfs. This adds a keyfile mechanism: at startup each public entry point calls _resolve_token(), which checks /etc/pve/priv/truenas-.key before falling back to truenas_api_key in storage.cfg. The resolved token is cached in the per-host $state so the file is read at most once per daemon session. truenas_api_key is now optional in storage.cfg; either the keyfile or the config key must be present. Docs updated in getting-started.md (section 4.1) and the in-app help HTML with setup commands and the cluster-copy reminder. Closes #261 Co-Authored-By: Claude Sonnet 4.6 --- docs/getting-started.md | 23 +++++++++++++ perl5/PVE/Storage/Custom/TrueNAS.pm | 50 +++++++++++++++++++++++++---- ui/truenas-storage-help.html | 21 ++++++++++-- 3 files changed, 85 insertions(+), 9 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 8d11491..ccec6a1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -140,6 +140,29 @@ Fill in the form using the table below. Fields not listed can be left at their d Click **Add**. Proxmox contacts TrueNAS and confirms the pool is reachable. If it fails, check the error message — see [section 6](#6-common-first-run-problems) for common causes. +### 4.1 Securing the API Token (Recommended for Production) + +By default the API token is stored in `/etc/pve/storage.cfg`, which is replicated in plaintext across all cluster nodes via the PVE cluster filesystem. For production deployments, move the token into a private keyfile that only root can read and that is not replicated. + +Run these commands **on each Proxmox node** after adding the storage: + +```bash +# Replace 'truenas-vms' with your actual storage ID +STORAGEID="truenas-vms" +KEYFILE="/etc/pve/priv/truenas-${STORAGEID}.key" + +# Write the token to the keyfile (replace the value with your actual token) +echo -n "your-api-token-here" > "$KEYFILE" +chmod 600 "$KEYFILE" + +# Remove the token from storage.cfg now that the keyfile is in place +pvesm set "$STORAGEID" --truenas_api_key "" +``` + +The plugin automatically checks `/etc/pve/priv/truenas-.key` at startup. If the file exists, it is used and `truenas_api_key` in storage.cfg is ignored. + +> **Note:** The keyfile must exist on **every Proxmox node** in your cluster. The `/etc/pve/priv/` directory is not replicated via pmxcfs — copy the file to each node manually (use `scp` or your configuration management tool). + --- ## 5. Creating Your First VM Disk on TrueNAS Storage diff --git a/perl5/PVE/Storage/Custom/TrueNAS.pm b/perl5/PVE/Storage/Custom/TrueNAS.pm index df15f94..d46d6d8 100644 --- a/perl5/PVE/Storage/Custom/TrueNAS.pm +++ b/perl5/PVE/Storage/Custom/TrueNAS.pm @@ -53,7 +53,8 @@ sub properties { type => 'string', }, truenas_api_key => { - description => "TrueNAS API key (Bearer token — generate in TrueNAS UI under Credentials > API Keys)", + description => "TrueNAS API key (Bearer token). " + . "Leave blank to use /etc/pve/priv/truenas-.key instead.", type => 'string', }, truenas_ssl => { @@ -98,7 +99,7 @@ sub options { bwlimit => { optional => 1 }, shared => { optional => 1 }, truenas_host => { fixed => 1 }, - truenas_api_key => {}, + truenas_api_key => { optional => 1 }, truenas_ssl => { optional => 1 }, truenas_ssl_verify => { optional => 1 }, truenas_pool => { fixed => 1 }, @@ -133,22 +134,51 @@ sub _ua { return $state->{$host}{ua}; } +# Resolves the API token from /etc/pve/priv/truenas-.key (preferred) +# or truenas_api_key in storage.cfg (backwards compat). Caches in $state so +# the keyfile is read at most once per daemon session per host. +sub _resolve_token { + my ($storeid, $scfg) = @_; + my $host = $scfg->{truenas_host}; + return if $state->{$host}{api_token}; + + my $keyfile = "/etc/pve/priv/truenas-$storeid.key"; + if (-r $keyfile) { + open(my $fh, '<', $keyfile) + or die "Cannot read TrueNAS token file '$keyfile': $!\n"; + my $token = do { local $/; <$fh> }; + close $fh; + $token =~ s/\s+\z//; + die "TrueNAS token file '$keyfile' is empty\n" unless length $token; + $state->{$host}{api_token} = $token; + return; + } + + die "TrueNAS API token not configured for storage '$storeid'. " + . "Set truenas_api_key in storage.cfg or create $keyfile\n" + unless $scfg->{truenas_api_key}; + + $state->{$host}{api_token} = $scfg->{truenas_api_key}; + return; +} + # Make a TrueNAS REST API v2.0 call. # Dies with a descriptive message on HTTP error. # Returns decoded JSON hashref/arrayref, or undef for empty 204 responses. sub _api { my ($scfg, $method, $path, $data) = @_; - die "TrueNAS API key is not configured for storage '$scfg->{truenas_host}'\n" - unless $scfg->{truenas_api_key}; + my $host = $scfg->{truenas_host}; + my $token = $state->{$host}{api_token} + or die "TrueNAS API token not resolved for '$host'\n"; my $scheme = ($scfg->{truenas_ssl} // 1) ? 'https' : 'http'; - my $url = "$scheme://$scfg->{truenas_host}/api/v2.0$path"; + my $url = "$scheme://$host/api/v2.0$path"; my $req = HTTP::Request->new(uc($method) => $url); $req->header('Content-Type' => 'application/json'); $req->header('Accept' => 'application/json'); - $req->header('Authorization' => "Bearer $scfg->{truenas_api_key}"); + $req->header('Authorization' => "Bearer $token"); $req->content(encode_json($data)) if defined $data; my $res = _ua($scfg)->request($req); @@ -365,6 +395,7 @@ sub parse_volname { sub status { my ($class, $storeid, $scfg, $cache) = @_; + _resolve_token($storeid, $scfg); my $pool_name = (split m{/}, $scfg->{truenas_pool})[0]; my $datasets = _api($scfg, 'GET', "/pool/dataset?id=$pool_name") // []; @@ -380,6 +411,7 @@ sub status { sub alloc_image { my ($class, $storeid, $scfg, $vmid, $fmt, $name, $size_kb) = @_; + _resolve_token($storeid, $scfg); die "Unsupported format '$fmt' — only raw is supported\n" if $fmt && $fmt ne 'raw'; @@ -425,6 +457,7 @@ sub alloc_image { sub free_image { my ($class, $storeid, $scfg, $volname, $isBase) = @_; + _resolve_token($storeid, $scfg); _log('info', "free_image: removing $volname"); @@ -469,6 +502,7 @@ sub free_image { sub list_images { my ($class, $storeid, $scfg, $vmid, $fmt, $ids) = @_; + _resolve_token($storeid, $scfg); my $prefix = _zvol_prefix($scfg); my $datasets = _api($scfg, 'GET', '/pool/dataset?type=VOLUME') // []; @@ -505,6 +539,7 @@ sub list_images { sub volume_size_info { my ($class, $scfg, $storeid, $volname, $timeout) = @_; + _resolve_token($storeid, $scfg); my $zvol = _zvol_prefix($scfg) . "/$volname"; my $enc = uri_escape($zvol, "^A-Za-z0-9\\-_.~"); @@ -514,6 +549,7 @@ sub volume_size_info { sub path { my ($class, $scfg, $volname, $storeid, $snapname) = @_; + _resolve_token($storeid, $scfg); my $ext = _find_extent($scfg, $volname); die "Volume '$volname' has no iSCSI extent on $scfg->{truenas_host}. " @@ -534,6 +570,7 @@ sub path { sub activate_storage { my ($class, $storeid, $scfg, $cache) = @_; + _resolve_token($storeid, $scfg); # Verify API reachability and warm up the global config cache _api_global($scfg); _log('info', "activate_storage: $storeid online"); @@ -549,6 +586,7 @@ sub deactivate_storage { sub activate_volume { my ($class, $storeid, $scfg, $volname, $snapname, $cache) = @_; + _resolve_token($storeid, $scfg); # QEMU connects via iscsi:// (libiscsi) — no iscsiadm session needed here. # Just verify the extent exists so we catch config errors early. my $ext = _find_extent($scfg, $volname); diff --git a/ui/truenas-storage-help.html b/ui/truenas-storage-help.html index 8a400bf..451de41 100644 --- a/ui/truenas-storage-help.html +++ b/ui/truenas-storage-help.html @@ -115,9 +115,9 @@ Each VM gets its own dedicated iSCSI target — no SSH keys or pre-created targe unless Portal IP is set separately. Cannot be changed after creation. - API KeyYes - Bearer token generated in TrueNAS. Stored in /etc/pve/storage.cfg. - Click the eye icon to show/hide while pasting. + API KeyNo* + Bearer token generated in TrueNAS. Click the eye icon to show/hide while pasting. + Leave blank if you are using a keyfile (see below). Pool / Dataset PathYes @@ -153,6 +153,21 @@ Each VM gets its own dedicated iSCSI target — no SSH keys or pre-created targe +

Securing the API Token (Recommended)

+

By default the API token is stored in /etc/pve/storage.cfg, which is replicated +in plaintext across the cluster. For production, store the token in a private keyfile instead:

+
+# Run on each Proxmox node — replace 'truenas-vms' with your storage ID
+STORAGEID="truenas-vms"
+KEYFILE="/etc/pve/priv/truenas-${STORAGEID}.key"
+echo -n "your-api-token-here" > "$KEYFILE"
+chmod 600 "$KEYFILE"
+pvesm set "$STORAGEID" --truenas_api_key ""
+
+

The plugin checks /etc/pve/priv/truenas-<storeid>.key automatically. +The keyfile must exist on every Proxmox node — copy it manually, as +/etc/pve/priv/ is not replicated across the cluster.

+

How It Works

When you create a VM disk, the plugin: