feat: read API token from /etc/pve/priv/truenas-<storeid>.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-<storeid>.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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adams 2026-05-25 22:36:21 -04:00
parent 2e0b5acb9a
commit f30862f3c0
3 changed files with 85 additions and 9 deletions

View File

@ -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-<storeid>.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

View File

@ -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-<storeid>.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-<storeid>.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);

View File

@ -115,9 +115,9 @@ Each VM gets its own dedicated iSCSI target — no SSH keys or pre-created targe
unless <code>Portal IP</code> is set separately. Cannot be changed after creation.</td>
</tr>
<tr>
<td><code>API Key</code></td><td>Yes</td>
<td>Bearer token generated in TrueNAS. Stored in <code>/etc/pve/storage.cfg</code>.
Click the eye icon to show/hide while pasting.</td>
<td><code>API Key</code></td><td>No*</td>
<td>Bearer token generated in TrueNAS. Click the eye icon to show/hide while pasting.
Leave blank if you are using a keyfile (see below).</td>
</tr>
<tr>
<td><code>Pool / Dataset Path</code></td><td>Yes</td>
@ -153,6 +153,21 @@ Each VM gets its own dedicated iSCSI target — no SSH keys or pre-created targe
</tr>
</table>
<h2>Securing the API Token (Recommended)</h2>
<p>By default the API token is stored in <code>/etc/pve/storage.cfg</code>, which is replicated
in plaintext across the cluster. For production, store the token in a private keyfile instead:</p>
<pre>
# 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" &gt; "$KEYFILE"
chmod 600 "$KEYFILE"
pvesm set "$STORAGEID" --truenas_api_key ""
</pre>
<p>The plugin checks <code>/etc/pve/priv/truenas-&lt;storeid&gt;.key</code> automatically.
The keyfile must exist on <strong>every Proxmox node</strong> — copy it manually, as
<code>/etc/pve/priv/</code> is not replicated across the cluster.</p>
<h2>How It Works</h2>
<p>When you create a VM disk, the plugin:</p>
<ol>