environment API uses a write-only model for the tls key and hawser token
This commit is contained in:
6 files changed
+125
-17
No files matched your search
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -9,13 +9,14 @@ import { resetHostDetection, detectHostDataDir } from '$lib/server/host-path';
|
||||
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
|
||||
import { cleanPem } from '$lib/utils/pem';
|
||||
import { validateEnvName } from '$lib/utils/env-name';
|
||||
import { redactEnvironment } from '$lib/server/environment-redact';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* 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-desc: Environments accessible to the caller (filtered by RBAC in Enterprise mode; all environments in Free edition)
|
||||
* 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: 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). 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","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-500: Unexpected error while loading environments
|
||||
*/
|
||||
@@ -55,7 +56,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const timezone = await getEnvironmentTimezone(env.id);
|
||||
const imagePruneSettings = await getImagePruneSettings(env.id);
|
||||
return {
|
||||
...env,
|
||||
...redactEnvironment(env),
|
||||
labels: parseLabels(env.labels as string | null),
|
||||
publicIp: publicIps[env.id.toString()] || null,
|
||||
updateCheckEnabled: updateSettings?.enabled || false,
|
||||
@@ -172,7 +173,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
// Audit log
|
||||
await auditEnvironment(event, 'create', env.id, env.name);
|
||||
|
||||
return json(env);
|
||||
return json(redactEnvironment(env));
|
||||
} catch (error) {
|
||||
console.error('Failed to create environment:', error);
|
||||
return json({ error: 'Failed to create environment' }, { status: 500 });
|
||||
|
||||
@@ -10,6 +10,7 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { auditEnvironment } from '$lib/server/audit';
|
||||
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
|
||||
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 { cleanPem } from '$lib/utils/pem';
|
||||
import { validateEnvName } from '$lib/utils/env-name';
|
||||
@@ -22,7 +23,8 @@ import { deleteEnvironmentIcon } from '$lib/server/env-icons';
|
||||
* @openapi
|
||||
* summary: Get a single environment by id, including its parsed labels and public IP
|
||||
* 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-404: Environment not found
|
||||
* 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
|
||||
return json({
|
||||
...env,
|
||||
...redactEnvironment(env),
|
||||
labels: parseLabels(env.labels as string | null),
|
||||
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
|
||||
* 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-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}
|
||||
* 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-403: Permission denied (RBAC 'environments:edit' missing)
|
||||
* 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) : [])
|
||||
: 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, {
|
||||
name: data.name,
|
||||
host: data.host,
|
||||
@@ -162,7 +173,7 @@ export const PUT: RequestHandler = async (event) => {
|
||||
protocol: data.protocol,
|
||||
tlsCa: cleanPem(data.tlsCa),
|
||||
tlsCert: cleanPem(data.tlsCert),
|
||||
tlsKey: cleanPem(data.tlsKey),
|
||||
tlsKey: cleanedTlsKey || undefined,
|
||||
tlsSkipVerify: data.tlsSkipVerify,
|
||||
icon: data.icon,
|
||||
socketPath: data.socketPath,
|
||||
@@ -171,7 +182,7 @@ export const PUT: RequestHandler = async (event) => {
|
||||
highlightChanges: data.highlightChanges,
|
||||
labels: labels,
|
||||
connectionType: data.connectionType,
|
||||
hawserToken: data.hawserToken
|
||||
hawserToken: trimmedToken || undefined
|
||||
});
|
||||
|
||||
if (!env) {
|
||||
@@ -207,7 +218,7 @@ export const PUT: RequestHandler = async (event) => {
|
||||
|
||||
// Parse labels from JSON string to array
|
||||
return json({
|
||||
...env,
|
||||
...redactEnvironment(env),
|
||||
labels: parseLabels(env.labels as string | null),
|
||||
publicIp
|
||||
});
|
||||
|
||||
@@ -121,7 +121,9 @@
|
||||
formProtocol = env.protocol;
|
||||
formTlsCa = env.tlsCa || '';
|
||||
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 = '';
|
||||
formSaving = false;
|
||||
showEditModal = true;
|
||||
|
||||
@@ -109,7 +109,10 @@
|
||||
protocol: string;
|
||||
tlsCa?: 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;
|
||||
icon?: string;
|
||||
socketPath?: string;
|
||||
@@ -122,6 +125,7 @@
|
||||
hawserAgentName?: string;
|
||||
hawserVersion?: string;
|
||||
hawserCapabilities?: string;
|
||||
labels?: string;
|
||||
publicIp?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -281,6 +285,9 @@
|
||||
let formTlsCa = $state('');
|
||||
let formTlsCert = $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 formIcon = $state('globe');
|
||||
let pendingIconData = $state<string | null>(null);
|
||||
@@ -303,6 +310,8 @@
|
||||
const usesStackPath = (ct: ConnectionType) => ct === 'direct' || ct === 'hawser-standard' || ct === 'hawser-edge';
|
||||
const isHawserConn = (ct: ConnectionType) => ct === 'hawser-standard' || ct === 'hawser-edge';
|
||||
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 newLabelInput = $state('');
|
||||
let showLabelDropdown = $state(false);
|
||||
@@ -578,7 +587,11 @@
|
||||
formProtocol = environment.protocol;
|
||||
formTlsCa = environment.tlsCa || '';
|
||||
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;
|
||||
formIcon = environment.icon || 'globe';
|
||||
formSocketPath = environment.socketPath || '/var/run/docker.sock';
|
||||
@@ -586,7 +599,8 @@
|
||||
formCollectMetrics = environment.collectMetrics ?? true;
|
||||
formHighlightChanges = environment.highlightChanges ?? true;
|
||||
formConnectionType = (environment.connectionType as ConnectionType) || 'socket';
|
||||
formHawserToken = environment.hawserToken || '';
|
||||
formHawserToken = '';
|
||||
hasStoredHawserToken = !!environment.hasHawserToken;
|
||||
formLabels = parseLabels(environment.labels);
|
||||
newLabelInput = '';
|
||||
formPublicIp = environment.publicIp || '';
|
||||
@@ -620,6 +634,7 @@
|
||||
formTlsCa = '';
|
||||
formTlsCert = '';
|
||||
formTlsKey = '';
|
||||
hasStoredTlsKey = false;
|
||||
formTlsSkipVerify = false;
|
||||
formIcon = 'globe';
|
||||
pendingIconData = null;
|
||||
@@ -633,6 +648,7 @@
|
||||
formDiskWarningThresholdGb = 50;
|
||||
formConnectionType = 'socket';
|
||||
formHawserToken = '';
|
||||
hasStoredHawserToken = false;
|
||||
formLabels = [];
|
||||
newLabelInput = '';
|
||||
formPublicIp = '';
|
||||
@@ -2137,7 +2153,7 @@
|
||||
<textarea
|
||||
id="edit-env-tls_key"
|
||||
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"
|
||||
></textarea>
|
||||
</div>
|
||||
@@ -2245,7 +2261,7 @@
|
||||
</Button>
|
||||
{/if}
|
||||
</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}
|
||||
<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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user