environment API uses a write-only model for the tls key and hawser token

This commit is contained in:
Jarek Krochmalski committed 2026-08-27 07:34:37 +02:00
1 parent bf518f98aa
commit 58f712d432
6 files changed
+125 -17

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
/**
* Strip an environment's decrypted secrets before it leaves the server.
*
* `getEnvironment(s)` decrypt `tlsKey` (the private TLS client key) and `hawserToken`
* for internal use, but neither must ever reach an API response: the TLS key
* authenticates directly against the Docker daemon, bypassing Dockhand entirely and
* surviving a revoked login. `tlsCa`/`tlsCert` are the public CA + client cert and
* are NOT secret, so they stay. In their place we expose `hasTlsKey` / `hasHawserToken`
* booleans so the UI can show "configured" without holding the value.
*/
/** The two secret fields removed from every environment API response. */
export type EnvironmentSecretField = 'tlsKey' | 'hawserToken';
export function redactEnvironment<T extends { tlsKey?: string | null; hawserToken?: string | null }>(
env: T
): Omit<T, EnvironmentSecretField> & { hasTlsKey: boolean; hasHawserToken: boolean } {
const { tlsKey, hawserToken, ...rest } = env;
return {
...rest,
hasTlsKey: !!(tlsKey && tlsKey.length > 0),
hasHawserToken: !!(hawserToken && hawserToken.length > 0)
};
}
+6 -5
View File
@@ -9,13 +9,14 @@ import { resetHostDetection, detectHostDataDir } from '$lib/server/host-path';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { cleanPem } from '$lib/utils/pem'; import { cleanPem } from '$lib/utils/pem';
import { validateEnvName } from '$lib/utils/env-name'; import { validateEnvName } from '$lib/utils/env-name';
import { redactEnvironment } from '$lib/server/environment-redact';
/** /**
* @openapi * @openapi
* summary: List all Docker environments (hosts) known to Dockhand * summary: List all Docker environments (hosts) known to Dockhand
* resp-200: array<{id:integer!, name:string!, connectionType:string!, host:string, port:integer, protocol:string, icon:string, publicIp:string, timezone:string}> * resp-200: array<{id:integer!, name:string!, connectionType:string!, host:string, port:integer, protocol:string, icon:string, publicIp:string, timezone:string, hasTlsKey:boolean, hasHawserToken:boolean}>
* resp-200-desc: Environments accessible to the caller (filtered by RBAC in Enterprise mode; all environments in Free edition) * resp-200-desc: Environments accessible to the caller (filtered by RBAC in Enterprise mode; all environments in Free edition). The tlsKey (private TLS client key) and hawserToken secrets are NEVER returned; hasTlsKey / hasHawserToken indicate whether one is stored.
* resp-200-example: [{"id":1,"name":"hhdocker01","connectionType":"socket","host":null,"port":2375,"protocol":"http","icon":"server","publicIp":"203.0.113.10","timezone":"Europe/Berlin"},{"id":2,"name":"hhdocker02","connectionType":"hawser-edge","host":null,"port":2375,"protocol":"http","icon":"server","publicIp":null,"timezone":"Europe/Berlin"}] * resp-200-example: [{"id":1,"name":"hhdocker01","connectionType":"socket","host":null,"port":2375,"protocol":"http","icon":"server","publicIp":"203.0.113.10","timezone":"Europe/Berlin","hasTlsKey":false,"hasHawserToken":false},{"id":2,"name":"hhdocker02","connectionType":"hawser-edge","host":null,"port":2375,"protocol":"http","icon":"server","publicIp":null,"timezone":"Europe/Berlin","hasTlsKey":false,"hasHawserToken":true}]
* resp-403: Permission denied (RBAC 'environments:view' missing) * resp-403: Permission denied (RBAC 'environments:view' missing)
* resp-500: Unexpected error while loading environments * resp-500: Unexpected error while loading environments
*/ */
@@ -55,7 +56,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
const timezone = await getEnvironmentTimezone(env.id); const timezone = await getEnvironmentTimezone(env.id);
const imagePruneSettings = await getImagePruneSettings(env.id); const imagePruneSettings = await getImagePruneSettings(env.id);
return { return {
...env, ...redactEnvironment(env),
labels: parseLabels(env.labels as string | null), labels: parseLabels(env.labels as string | null),
publicIp: publicIps[env.id.toString()] || null, publicIp: publicIps[env.id.toString()] || null,
updateCheckEnabled: updateSettings?.enabled || false, updateCheckEnabled: updateSettings?.enabled || false,
@@ -172,7 +173,7 @@ export const POST: RequestHandler = async (event) => {
// Audit log // Audit log
await auditEnvironment(event, 'create', env.id, env.name); await auditEnvironment(event, 'create', env.id, env.name);
return json(env); return json(redactEnvironment(env));
} catch (error) { } catch (error) {
console.error('Failed to create environment:', error); console.error('Failed to create environment:', error);
return json({ error: 'Failed to create environment' }, { status: 500 }); return json({ error: 'Failed to create environment' }, { status: 500 });
+17 -6
View File
@@ -10,6 +10,7 @@ import { authorize } from '$lib/server/authorize';
import { auditEnvironment } from '$lib/server/audit'; import { auditEnvironment } from '$lib/server/audit';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager'; import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { resetHostDetection, detectHostDataDir } from '$lib/server/host-path'; import { resetHostDetection, detectHostDataDir } from '$lib/server/host-path';
import { redactEnvironment } from '$lib/server/environment-redact';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { cleanPem } from '$lib/utils/pem'; import { cleanPem } from '$lib/utils/pem';
import { validateEnvName } from '$lib/utils/env-name'; import { validateEnvName } from '$lib/utils/env-name';
@@ -22,7 +23,8 @@ import { deleteEnvironmentIcon } from '$lib/server/env-icons';
* @openapi * @openapi
* summary: Get a single environment by id, including its parsed labels and public IP * summary: Get a single environment by id, including its parsed labels and public IP
* path: id:integer! Environment id (from GET /api/environments) * path: id:integer! Environment id (from GET /api/environments)
* resp-200: {id:integer!, name:string!, connectionType:string!, labels:array<string>, publicIp:string} * resp-200: {id:integer!, name:string!, connectionType:string!, labels:array<string>, publicIp:string, hasTlsKey:boolean, hasHawserToken:boolean}
* resp-200-desc: The tlsKey (private TLS client key) and hawserToken secrets are NEVER returned; hasTlsKey / hasHawserToken indicate whether one is stored.
* resp-403: Permission denied (RBAC 'environments:view' missing) * resp-403: Permission denied (RBAC 'environments:view' missing)
* resp-404: Environment not found * resp-404: Environment not found
* resp-500: Unexpected error while loading the environment * resp-500: Unexpected error while loading the environment
@@ -47,7 +49,7 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
// Parse labels from JSON string to array // Parse labels from JSON string to array
return json({ return json({
...env, ...redactEnvironment(env),
labels: parseLabels(env.labels as string | null), labels: parseLabels(env.labels as string | null),
publicIp publicIp
}); });
@@ -62,8 +64,10 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
* summary: Update an environment; renaming also renames its on-disk stacks/git-repos directories * summary: Update an environment; renaming also renames its on-disk stacks/git-repos directories
* path: id:integer! Environment id (from GET /api/environments) * path: id:integer! Environment id (from GET /api/environments)
* body: {name:string, host:string, port:integer, protocol:string, tlsCa:string, tlsCert:string, tlsKey:string, tlsSkipVerify:boolean, icon:string, socketPath:string, collectActivity:boolean, collectMetrics:boolean, highlightChanges:boolean, labels:string, connectionType:string, hawserToken:string, publicIp:string} * body: {name:string, host:string, port:integer, protocol:string, tlsCa:string, tlsCert:string, tlsKey:string, tlsSkipVerify:boolean, icon:string, socketPath:string, collectActivity:boolean, collectMetrics:boolean, highlightChanges:boolean, labels:string, connectionType:string, hawserToken:string, publicIp:string}
* body-desc: tlsKey/hawserToken are write-only - a blank value keeps the stored secret; a non-blank value replaces it.
* body-example: {"name":"hhdocker03","collectMetrics":true} * body-example: {"name":"hhdocker03","collectMetrics":true}
* resp-200: {id:integer!, name:string!, connectionType:string!, labels:array<string>, publicIp:string} * resp-200: {id:integer!, name:string!, connectionType:string!, labels:array<string>, publicIp:string, hasTlsKey:boolean, hasHawserToken:boolean}
* resp-200-desc: The tlsKey/hawserToken secrets are NEVER returned; hasTlsKey / hasHawserToken indicate whether one is stored.
* resp-400: Invalid new name (rename validation) * resp-400: Invalid new name (rename validation)
* resp-403: Permission denied (RBAC 'environments:edit' missing) * resp-403: Permission denied (RBAC 'environments:edit' missing)
* resp-404: Environment not found * resp-404: Environment not found
@@ -155,6 +159,13 @@ export const PUT: RequestHandler = async (event) => {
? serializeLabels(Array.isArray(data.labels) ? data.labels.slice(0, MAX_LABELS) : []) ? serializeLabels(Array.isArray(data.labels) ? data.labels.slice(0, MAX_LABELS) : [])
: undefined; : undefined;
// The GET/list responses never return the tlsKey / hawserToken secrets, so the
// edit form can't round-trip them. A blank value therefore means "keep the stored
// secret" (pass undefined so updateEnvironment leaves the column untouched); a
// non-blank value replaces it. Same "leave blank to keep" pattern as registries /
// git credentials. (Removing a secret entirely is done by deleting the env.)
const cleanedTlsKey = cleanPem(data.tlsKey);
const trimmedToken = typeof data.hawserToken === 'string' ? data.hawserToken.trim() : data.hawserToken;
const env = await updateEnvironment(id, { const env = await updateEnvironment(id, {
name: data.name, name: data.name,
host: data.host, host: data.host,
@@ -162,7 +173,7 @@ export const PUT: RequestHandler = async (event) => {
protocol: data.protocol, protocol: data.protocol,
tlsCa: cleanPem(data.tlsCa), tlsCa: cleanPem(data.tlsCa),
tlsCert: cleanPem(data.tlsCert), tlsCert: cleanPem(data.tlsCert),
tlsKey: cleanPem(data.tlsKey), tlsKey: cleanedTlsKey || undefined,
tlsSkipVerify: data.tlsSkipVerify, tlsSkipVerify: data.tlsSkipVerify,
icon: data.icon, icon: data.icon,
socketPath: data.socketPath, socketPath: data.socketPath,
@@ -171,7 +182,7 @@ export const PUT: RequestHandler = async (event) => {
highlightChanges: data.highlightChanges, highlightChanges: data.highlightChanges,
labels: labels, labels: labels,
connectionType: data.connectionType, connectionType: data.connectionType,
hawserToken: data.hawserToken hawserToken: trimmedToken || undefined
}); });
if (!env) { if (!env) {
@@ -207,7 +218,7 @@ export const PUT: RequestHandler = async (event) => {
// Parse labels from JSON string to array // Parse labels from JSON string to array
return json({ return json({
...env, ...redactEnvironment(env),
labels: parseLabels(env.labels as string | null), labels: parseLabels(env.labels as string | null),
publicIp publicIp
}); });
+3 -1
View File
@@ -121,7 +121,9 @@
formProtocol = env.protocol; formProtocol = env.protocol;
formTlsCa = env.tlsCa || ''; formTlsCa = env.tlsCa || '';
formTlsCert = env.tlsCert || ''; formTlsCert = env.tlsCert || '';
formTlsKey = env.tlsKey || ''; // The API never returns the tlsKey secret; leave blank on edit (a blank submit
// keeps the stored key via `tlsKey: formTlsKey || undefined` below).
formTlsKey = '';
formError = ''; formError = '';
formSaving = false; formSaving = false;
showEditModal = true; showEditModal = true;
@@ -109,7 +109,10 @@
protocol: string; protocol: string;
tlsCa?: string; tlsCa?: string;
tlsCert?: string; tlsCert?: string;
tlsKey?: string; // tlsKey / hawserToken are never returned by the API (write-only secrets); the
// has* flags say whether one is stored.
hasTlsKey?: boolean;
hasHawserToken?: boolean;
tlsSkipVerify?: boolean; tlsSkipVerify?: boolean;
icon?: string; icon?: string;
socketPath?: string; socketPath?: string;
@@ -122,6 +125,7 @@
hawserAgentName?: string; hawserAgentName?: string;
hawserVersion?: string; hawserVersion?: string;
hawserCapabilities?: string; hawserCapabilities?: string;
labels?: string;
publicIp?: string; publicIp?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -281,6 +285,9 @@
let formTlsCa = $state(''); let formTlsCa = $state('');
let formTlsCert = $state(''); let formTlsCert = $state('');
let formTlsKey = $state(''); let formTlsKey = $state('');
// Whether the environment already has a stored tlsKey (the value itself is never
// returned by the API). Drives the "leave blank to keep" hint on the field.
let hasStoredTlsKey = $state(false);
let formTlsSkipVerify = $state(false); let formTlsSkipVerify = $state(false);
let formIcon = $state('globe'); let formIcon = $state('globe');
let pendingIconData = $state<string | null>(null); let pendingIconData = $state<string | null>(null);
@@ -303,6 +310,8 @@
const usesStackPath = (ct: ConnectionType) => ct === 'direct' || ct === 'hawser-standard' || ct === 'hawser-edge'; const usesStackPath = (ct: ConnectionType) => ct === 'direct' || ct === 'hawser-standard' || ct === 'hawser-edge';
const isHawserConn = (ct: ConnectionType) => ct === 'hawser-standard' || ct === 'hawser-edge'; const isHawserConn = (ct: ConnectionType) => ct === 'hawser-standard' || ct === 'hawser-edge';
let formHawserToken = $state(''); let formHawserToken = $state('');
// Whether a hawser token is already stored (value never returned by the API).
let hasStoredHawserToken = $state(false);
let formLabels = $state<string[]>([]); let formLabels = $state<string[]>([]);
let newLabelInput = $state(''); let newLabelInput = $state('');
let showLabelDropdown = $state(false); let showLabelDropdown = $state(false);
@@ -578,7 +587,11 @@
formProtocol = environment.protocol; formProtocol = environment.protocol;
formTlsCa = environment.tlsCa || ''; formTlsCa = environment.tlsCa || '';
formTlsCert = environment.tlsCert || ''; formTlsCert = environment.tlsCert || '';
formTlsKey = environment.tlsKey || ''; // The API never returns the tlsKey / hawserToken secrets (they're stripped
// server-side). Start blank and remember whether one is stored so the field
// can say "leave blank to keep"; a blank submit keeps the existing secret.
formTlsKey = '';
hasStoredTlsKey = !!environment.hasTlsKey;
formTlsSkipVerify = environment.tlsSkipVerify ?? false; formTlsSkipVerify = environment.tlsSkipVerify ?? false;
formIcon = environment.icon || 'globe'; formIcon = environment.icon || 'globe';
formSocketPath = environment.socketPath || '/var/run/docker.sock'; formSocketPath = environment.socketPath || '/var/run/docker.sock';
@@ -586,7 +599,8 @@
formCollectMetrics = environment.collectMetrics ?? true; formCollectMetrics = environment.collectMetrics ?? true;
formHighlightChanges = environment.highlightChanges ?? true; formHighlightChanges = environment.highlightChanges ?? true;
formConnectionType = (environment.connectionType as ConnectionType) || 'socket'; formConnectionType = (environment.connectionType as ConnectionType) || 'socket';
formHawserToken = environment.hawserToken || ''; formHawserToken = '';
hasStoredHawserToken = !!environment.hasHawserToken;
formLabels = parseLabels(environment.labels); formLabels = parseLabels(environment.labels);
newLabelInput = ''; newLabelInput = '';
formPublicIp = environment.publicIp || ''; formPublicIp = environment.publicIp || '';
@@ -620,6 +634,7 @@
formTlsCa = ''; formTlsCa = '';
formTlsCert = ''; formTlsCert = '';
formTlsKey = ''; formTlsKey = '';
hasStoredTlsKey = false;
formTlsSkipVerify = false; formTlsSkipVerify = false;
formIcon = 'globe'; formIcon = 'globe';
pendingIconData = null; pendingIconData = null;
@@ -633,6 +648,7 @@
formDiskWarningThresholdGb = 50; formDiskWarningThresholdGb = 50;
formConnectionType = 'socket'; formConnectionType = 'socket';
formHawserToken = ''; formHawserToken = '';
hasStoredHawserToken = false;
formLabels = []; formLabels = [];
newLabelInput = ''; newLabelInput = '';
formPublicIp = ''; formPublicIp = '';
@@ -2137,7 +2153,7 @@
<textarea <textarea
id="edit-env-tls_key" id="edit-env-tls_key"
bind:value={formTlsKey} bind:value={formTlsKey}
placeholder="-----BEGIN PRIVATE KEY-----" placeholder={hasStoredTlsKey ? 'Configured - leave blank to keep, or paste a new key to replace it' : '-----BEGIN PRIVATE KEY-----'}
class="flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" class="flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
></textarea> ></textarea>
</div> </div>
@@ -2245,7 +2261,7 @@
</Button> </Button>
{/if} {/if}
</div> </div>
<Input id="edit-env-hawser-token" type="password" bind:value={formHawserToken} placeholder="Token for agent authentication" oninput={() => generatedStandardToken = null} /> <Input id="edit-env-hawser-token" type="password" bind:value={formHawserToken} placeholder={hasStoredHawserToken ? 'Configured - leave blank to keep, or enter a new token to replace it' : 'Token for agent authentication'} oninput={() => generatedStandardToken = null} />
{#if generatedStandardToken} {#if generatedStandardToken}
<div class="p-3 bg-amber-50 dark:bg-amber-950/30 border border-amber-300 dark:border-amber-700 rounded-md space-y-2"> <div class="p-3 bg-amber-50 dark:bg-amber-950/30 border border-amber-300 dark:border-amber-700 rounded-md space-y-2">
<p class="text-xs font-medium text-amber-700 dark:text-amber-400 flex items-center gap-1"> <p class="text-xs font-medium text-amber-700 dark:text-amber-400 flex items-center gap-1">
+54
View File
@@ -0,0 +1,54 @@
/**
* redactEnvironment: strip the tlsKey / hawserToken secrets from an environment before
* it leaves the server, replacing them with hasTlsKey / hasHawserToken booleans.
* The private TLS client key authenticates straight to the Docker daemon, so it must
* never appear in an API response.
*/
import { describe, it, expect } from 'bun:test';
import { redactEnvironment } from '../src/lib/server/environment-redact';
describe('redactEnvironment', () => {
it('removes tlsKey and hawserToken from the output', () => {
const out = redactEnvironment({
id: 1,
name: 'prod',
tlsKey: '-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----',
hawserToken: 'super-secret-token',
tlsCa: 'CA',
tlsCert: 'CERT'
} as any);
expect('tlsKey' in out).toBe(false);
expect('hawserToken' in out).toBe(false);
// the JSON serialization a client would see must not contain the secret text
expect(JSON.stringify(out)).not.toContain('SECRET');
expect(JSON.stringify(out)).not.toContain('super-secret-token');
});
it('sets hasTlsKey / hasHawserToken true when a secret is present', () => {
const out = redactEnvironment({ id: 1, name: 'p', tlsKey: 'k', hawserToken: 't' } as any);
expect(out.hasTlsKey).toBe(true);
expect(out.hasHawserToken).toBe(true);
});
it('sets the flags false when the secret is null / empty', () => {
const out = redactEnvironment({ id: 1, name: 'p', tlsKey: null, hawserToken: '' } as any);
expect(out.hasTlsKey).toBe(false);
expect(out.hasHawserToken).toBe(false);
});
it('keeps the public tlsCa / tlsCert and all other fields', () => {
const out = redactEnvironment({
id: 7,
name: 'p',
tlsCa: 'CA-PUBLIC',
tlsCert: 'CERT-PUBLIC',
connectionType: 'direct',
tlsKey: 'k',
hawserToken: 't'
} as any) as any;
expect(out.tlsCa).toBe('CA-PUBLIC');
expect(out.tlsCert).toBe('CERT-PUBLIC');
expect(out.connectionType).toBe('direct');
expect(out.id).toBe(7);
});
});