feat: add confirmed Take down stack action with optional volume removal (#1599)

* feat: add confirmed Take down stack action with optional volume removal

Expose Take down in the stack header and sidebar with a confirmation dialog
that runs compose down while keeping the stack definition on disk. Optional
volume removal is gated by node capability and stack:deploy permission, with
remote gateway preflight before proxying removeVolumes requests.

Closes #1582

* fix: reset take-down volume checkbox when dialog closes

* test: align getStackMenuVisibility assertions with showTakeDown key

getStackMenuVisibility now returns a fifth lifecycle flag, showTakeDown,
but three exhaustive toEqual assertions still listed only the prior four
keys and failed. Add the expected showTakeDown value to each: true for
the partial and exited running-stack cases, false for the self stack.

* test: cover Take down visibility for running non-self stacks

The getStackMenuVisibility assertions exercised the partial and exited
branches and the self-stack guard, but not the raw === 'running' literal
that drives showTakeDown for a normal running stack. Add a case so a
regression dropping 'running' from that check is caught.

* fix: drop Take down from header overflow and wire activity shortcut

Remove duplicate Take down from More actions.

Keep inline button when running, sidebar menu, and Cmd+ArrowDown.

Record stack_taken_down in activity on successful POST /down.
This commit is contained in:
Anso
2026-07-09 12:20:13 -04:00
committed by GitHub
parent 296ddff2a0
commit d113004359
48 changed files with 952 additions and 110 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ describe('getAuditSummary()', () => {
});
it('resolves wildcard match: POST /stacks/mystack/down', () => {
expect(getAuditSummary('POST', '/stacks/mystack/down')).toBe('Stopped stack: mystack');
expect(getAuditSummary('POST', '/stacks/mystack/down')).toBe('Took stack down: mystack');
});
it('resolves wildcard match: POST /stacks/mystack/rollback', () => {
@@ -1093,6 +1093,49 @@ describe('ComposeService - withRegistryAuth', () => {
// ── downStack ──────────────────────────────────────────────────────────
describe('ComposeService - runDown', () => {
it('runs plain docker compose down by default', async () => {
setupAutoCloseSpawn();
const svc = ComposeService.getInstance(1);
await svc.runDown('my-stack');
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
['compose', 'down'],
expect.any(Object)
);
});
it('runs docker compose down --volumes when removeVolumes is true', async () => {
setupAutoCloseSpawn();
const svc = ComposeService.getInstance(1);
await svc.runDown('my-stack', { removeVolumes: true });
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
['compose', 'down', '--volumes'],
expect.any(Object)
);
});
it('does not pass --volumes when removeVolumes is false', async () => {
setupAutoCloseSpawn();
const svc = ComposeService.getInstance(1);
await svc.runDown('my-stack', { removeVolumes: false });
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
['compose', 'down'],
expect.any(Object)
);
});
});
// ── downStack ──────────────────────────────────────────────────────────
describe('ComposeService - downStack', () => {
it('runs docker compose down with volumes and remove-orphans', async () => {
setupAutoCloseSpawn();
@@ -0,0 +1,122 @@
/**
* Gateway preflight for POST /stacks/:name/down?removeVolumes=true on remote nodes.
* Unsupported remotes must return 400 before the proxy forwards the request.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'http';
import bcrypt from 'bcrypt';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let viewerBearer: string;
let adminBearer: string;
let capServer: http.Server;
let noCapServer: http.Server;
let capNodeId: number;
let noCapNodeId: number;
const noCapPaths: string[] = [];
const capPaths: string[] = [];
function metaServer(capabilities: string[], seen: string[]): http.Server {
return http.createServer((req, res) => {
if (req.url) seen.push(req.url);
res.writeHead(200, { 'Content-Type': 'application/json' });
if (req.url?.startsWith('/api/meta')) {
res.end(JSON.stringify({ version: '0.93.0', capabilities }));
} else {
res.end(JSON.stringify({ status: 'Command started' }));
}
});
}
async function listen(server: http.Server): Promise<number> {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
return (server.address() as import('net').AddressInfo).port;
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
db.addUser({ username: 'vol-viewer', password_hash: hash, role: 'viewer' });
const viewer = db.getUserByUsername('vol-viewer')!;
viewerBearer = jwt.sign({ username: 'vol-viewer', role: 'viewer', tv: viewer.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' });
adminBearer = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
capServer = metaServer(['cross-node-rbac', 'stack-down-remove-volumes'], capPaths);
noCapServer = metaServer(['cross-node-rbac'], noCapPaths);
const capPort = await listen(capServer);
const noCapPort = await listen(noCapServer);
capNodeId = db.addNode({
name: 'vol-cap-remote', type: 'remote', mode: 'proxy', compose_dir: '/tmp',
is_default: false, api_url: `http://127.0.0.1:${capPort}`, api_token: 'cap-token',
});
noCapNodeId = db.addNode({
name: 'vol-nocap-remote', type: 'remote', mode: 'proxy', compose_dir: '/tmp',
is_default: false, api_url: `http://127.0.0.1:${noCapPort}`, api_token: 'nocap-token',
});
});
afterAll(async () => {
await new Promise<void>((resolve) => capServer.close(() => resolve()));
await new Promise<void>((resolve) => noCapServer.close(() => resolve()));
cleanupTestDb(tmpDir);
});
describe('remote proxy stack-down volume gate', () => {
it('returns 400 for removeVolumes=true when remote lacks stack-down-remove-volumes (admin)', async () => {
noCapPaths.length = 0;
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=true')
.set('Authorization', `Bearer ${adminBearer}`)
.set('x-node-id', String(noCapNodeId));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not supported/i);
expect(noCapPaths.some(p => p.includes('/api/stacks/web/down'))).toBe(false);
expect(noCapPaths.some(p => p.startsWith('/api/meta'))).toBe(true);
});
it('returns 400 for removeVolumes=true when remote lacks capability (non-admin)', async () => {
noCapPaths.length = 0;
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=true')
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(noCapNodeId));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not supported/i);
expect(noCapPaths.some(p => p.includes('/api/stacks/web/down'))).toBe(false);
});
it('proxies removeVolumes=true when remote advertises stack-down-remove-volumes', async () => {
capPaths.length = 0;
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=true')
.set('Authorization', `Bearer ${adminBearer}`)
.set('x-node-id', String(capNodeId));
expect(res.status).toBe(200);
expect(capPaths.some(p => p.includes('/api/stacks/web/down'))).toBe(true);
});
it('does not preflight plain down without removeVolumes', async () => {
noCapPaths.length = 0;
const res = await request(app)
.post('/api/stacks/web/down')
.set('Authorization', `Bearer ${adminBearer}`)
.set('x-node-id', String(noCapNodeId));
expect(res.status).toBe(200);
expect(noCapPaths.some(p => p.includes('/api/stacks/web/down'))).toBe(true);
expect(noCapPaths.some(p => p.startsWith('/api/meta'))).toBe(false);
});
});
@@ -10,6 +10,7 @@ import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import type { RemoteMeta } from '../services/CapabilityRegistry';
let remoteSupportsCrossNodeRbac: typeof import('../helpers/remoteCapabilities').remoteSupportsCrossNodeRbac;
let remoteAdvertisesCapability: typeof import('../helpers/remoteCapabilities').remoteAdvertisesCapability;
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
let tmpDir: string;
@@ -17,7 +18,7 @@ const NODE_ID = 4242;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ remoteSupportsCrossNodeRbac } = await import('../helpers/remoteCapabilities'));
({ remoteSupportsCrossNodeRbac, remoteAdvertisesCapability } = await import('../helpers/remoteCapabilities'));
({ NodeRegistry } = await import('../services/NodeRegistry'));
});
@@ -79,3 +80,23 @@ describe('remoteSupportsCrossNodeRbac', () => {
expect(spy).toHaveBeenCalledTimes(1);
});
});
describe('remoteAdvertisesCapability', () => {
it('returns true when the remote advertises the requested capability', async () => {
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode')
.mockResolvedValue({ version: '0.93.0', capabilities: ['stack-down-remove-volumes'], ...ONLINE });
expect(await remoteAdvertisesCapability(NODE_ID, 'stack-down-remove-volumes')).toBe(true);
});
it('dedupes concurrent probes per node+capability, not per node alone', async () => {
const spy = vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode')
.mockResolvedValue({ version: '0.93.0', capabilities: ['cross-node-rbac', 'stack-down-remove-volumes'], ...ONLINE });
const [rbac, volumes] = await Promise.all([
remoteAdvertisesCapability(NODE_ID, 'cross-node-rbac'),
remoteAdvertisesCapability(NODE_ID, 'stack-down-remove-volumes'),
]);
expect(rbac).toBe(true);
expect(volumes).toBe(true);
expect(spy).toHaveBeenCalledTimes(2);
});
});
@@ -18,10 +18,12 @@ import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTes
const {
mockDeployStack,
mockRunCommand,
mockRunDown,
mockUpdateStack,
} = vi.hoisted(() => ({
mockDeployStack: vi.fn(),
mockRunCommand: vi.fn(),
mockRunDown: vi.fn(),
mockUpdateStack: vi.fn(),
}));
@@ -36,6 +38,7 @@ vi.mock('../services/ComposeService', async () => {
getInstance: () => ({
deployStack: mockDeployStack,
runCommand: mockRunCommand,
runDown: mockRunDown,
updateStack: mockUpdateStack,
}),
},
@@ -72,8 +75,8 @@ describe('POST /api/stacks/:stackName/deploy on a nonexistent stack', () => {
});
describe('POST /api/stacks/:stackName/down on a nonexistent stack', () => {
it('returns 404 with "Stack not found" and never enters ComposeService.runCommand', async () => {
mockRunCommand.mockClear();
it('returns 404 with "Stack not found" and never enters ComposeService.runDown', async () => {
mockRunDown.mockClear();
const res = await request(app)
.post('/api/stacks/does-not-exist-f7/down')
@@ -81,7 +84,7 @@ describe('POST /api/stacks/:stackName/down on a nonexistent stack', () => {
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Stack not found' });
expect(mockRunCommand).not.toHaveBeenCalled();
expect(mockRunDown).not.toHaveBeenCalled();
});
});
@@ -18,6 +18,7 @@ import { isDockerUnavailableError } from '../routes/stacks';
const {
mockDeployStack,
mockRunCommand,
mockRunDown,
mockUpdateStack,
mockGetContainersByStack,
mockRestartContainer,
@@ -26,6 +27,7 @@ const {
} = vi.hoisted(() => ({
mockDeployStack: vi.fn(),
mockRunCommand: vi.fn(),
mockRunDown: vi.fn(),
mockUpdateStack: vi.fn(),
mockGetContainersByStack: vi.fn(),
mockRestartContainer: vi.fn(),
@@ -44,6 +46,7 @@ vi.mock('../services/ComposeService', async () => {
getInstance: () => ({
deployStack: mockDeployStack,
runCommand: mockRunCommand,
runDown: mockRunDown,
updateStack: mockUpdateStack,
}),
},
@@ -98,6 +101,7 @@ afterAll(() => {
beforeEach(() => {
mockDeployStack.mockReset();
mockRunCommand.mockReset();
mockRunDown.mockReset();
mockUpdateStack.mockReset();
mockGetContainersByStack.mockReset();
mockRestartContainer.mockReset();
@@ -211,8 +215,8 @@ describe('POST /api/stacks/:name/deploy with daemon down', () => {
});
describe('POST /api/stacks/:name/down with daemon down', () => {
it('returns 503 with code: docker_unavailable when runCommand surfaces the daemon error', async () => {
mockRunCommand.mockRejectedValue(composeDaemonDownError());
it('returns 503 with code: docker_unavailable when runDown surfaces the daemon error', async () => {
mockRunDown.mockRejectedValue(composeDaemonDownError());
const res = await request(app)
.post('/api/stacks/web/down')
@@ -0,0 +1,181 @@
/**
* Route tests for POST /api/stacks/:name/down and optional ?removeVolumes=true.
*/
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
import { generateApiToken } from '../utils/apiTokenFormat';
import {
disableCapability,
enableCapability,
STACK_DOWN_REMOVE_VOLUMES_CAPABILITY,
} from '../services/CapabilityRegistry';
const { mockRunDown } = vi.hoisted(() => ({
mockRunDown: vi.fn(),
}));
vi.mock('../services/ComposeService', async () => {
const actual = await vi.importActual<typeof import('../services/ComposeService')>(
'../services/ComposeService',
);
return {
...actual,
ComposeService: {
...actual.ComposeService,
getInstance: () => ({
runDown: mockRunDown,
}),
},
};
});
let tmpDir: string;
let app: import('express').Express;
let authCookie: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let dispatchAlertSpy: ReturnType<typeof vi.spyOn>;
function writeStack(name: string) {
const dir = path.join(process.env.COMPOSE_DIR!, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'compose.yaml'), 'services:\n web:\n image: nginx\n');
}
function createDeployOnlyToken(): string {
const rawToken = generateApiToken();
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const db = DatabaseService.getInstance();
db.addApiToken({
token_hash: tokenHash,
name: `deploy-only-down-${Date.now()}`,
scope: 'deploy-only',
user_id: db.getUserByUsername('testadmin')!.id,
created_at: Date.now(),
expires_at: null,
});
return rawToken;
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ app } = await import('../index'));
authCookie = await loginAsTestAdmin(app);
writeStack('web');
const { NotificationService } = await import('../services/NotificationService');
dispatchAlertSpy = vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockResolvedValue(undefined);
});
afterAll(() => {
vi.restoreAllMocks();
enableCapability(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY);
cleanupTestDb(tmpDir);
});
beforeEach(async () => {
mockRunDown.mockReset();
mockRunDown.mockResolvedValue(undefined);
enableCapability(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY);
const { StackOpLockService } = await import('../services/StackOpLockService');
StackOpLockService.resetForTests();
});
describe('POST /api/stacks/:name/down removeVolumes', () => {
it('runs plain down when removeVolumes is omitted', async () => {
const res = await request(app)
.post('/api/stacks/web/down')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: false }, undefined);
});
it('dispatches stack_taken_down activity on plain down success', async () => {
dispatchAlertSpy.mockClear();
mockRunDown.mockResolvedValue(undefined);
const res = await request(app)
.post('/api/stacks/web/down')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(dispatchAlertSpy).toHaveBeenCalledWith(
'info',
'stack_taken_down',
'web taken down',
expect.objectContaining({ stackName: 'web', actor: 'testadmin' }),
);
});
it('notes volume removal in the activity message when removeVolumes=true', async () => {
dispatchAlertSpy.mockClear();
mockRunDown.mockResolvedValue(undefined);
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=true')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(dispatchAlertSpy).toHaveBeenCalledWith(
'info',
'stack_taken_down',
'web taken down (volumes removed)',
expect.objectContaining({ stackName: 'web' }),
);
});
it('runs plain down when removeVolumes=false', async () => {
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=false')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: false }, undefined);
});
it('does not enable volumes when removeVolumes=1 (only exact true)', async () => {
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=1')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: false }, undefined);
});
it('returns 400 when removeVolumes=true but capability is absent locally', async () => {
disableCapability(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY);
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=true')
.set('Cookie', authCookie);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not supported/i);
expect(mockRunDown).not.toHaveBeenCalled();
});
it('passes removeVolumes=true to runDown when capability is present', async () => {
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=true')
.set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: true }, undefined);
});
it('allows deploy-only API tokens to POST /down?removeVolumes=true', async () => {
const token = createDeployOnlyToken();
const res = await request(app)
.post('/api/stacks/web/down?removeVolumes=true')
.set('Authorization', `Bearer ${token}`);
expect(res.body.code).not.toBe('SCOPE_DENIED');
expect(res.status).toBe(200);
expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: true }, undefined);
});
});
@@ -17,6 +17,7 @@ import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTes
const {
mockDeployStack,
mockRunCommand,
mockRunDown,
mockUpdateStack,
mockGetContainersByStack,
mockRestartContainer,
@@ -25,6 +26,7 @@ const {
} = vi.hoisted(() => ({
mockDeployStack: vi.fn(),
mockRunCommand: vi.fn(),
mockRunDown: vi.fn(),
mockUpdateStack: vi.fn(),
mockGetContainersByStack: vi.fn(),
mockRestartContainer: vi.fn(),
@@ -43,6 +45,7 @@ vi.mock('../services/ComposeService', async () => {
getInstance: () => ({
deployStack: mockDeployStack,
runCommand: mockRunCommand,
runDown: mockRunDown,
updateStack: mockUpdateStack,
}),
},
@@ -97,6 +100,7 @@ afterAll(() => {
beforeEach(async () => {
mockDeployStack.mockReset();
mockRunCommand.mockReset();
mockRunDown.mockReset();
mockUpdateStack.mockReset();
mockGetContainersByStack.mockReset();
mockRestartContainer.mockReset();
@@ -233,13 +237,13 @@ describe('Stack lifecycle mutex', () => {
it('blocks update while down is in flight', async () => {
const gate = deferred<void>();
mockRunCommand.mockImplementationOnce(() => gate.promise);
mockRunDown.mockImplementationOnce(() => gate.promise);
const down = request(app)
.post('/api/stacks/web/down')
.set('Cookie', authCookie)
.then(r => r);
await vi.waitFor(() => expect(mockRunCommand).toHaveBeenCalled());
await vi.waitFor(() => expect(mockRunDown).toHaveBeenCalled());
const update = await request(app)
.post('/api/stacks/web/update')
@@ -13,6 +13,7 @@ import { SELF_STACK_PROTECTED_CODE } from '../helpers/selfStackGuard';
const {
mockDeployStack,
mockRunCommand,
mockRunDown,
mockUpdateStack,
mockDownStack,
mockGetContainersByStack,
@@ -22,6 +23,7 @@ const {
} = vi.hoisted(() => ({
mockDeployStack: vi.fn(),
mockRunCommand: vi.fn(),
mockRunDown: vi.fn(),
mockUpdateStack: vi.fn(),
mockDownStack: vi.fn(),
mockGetContainersByStack: vi.fn(),
@@ -41,6 +43,7 @@ vi.mock('../services/ComposeService', async () => {
getInstance: () => ({
deployStack: mockDeployStack,
runCommand: mockRunCommand,
runDown: mockRunDown,
updateStack: mockUpdateStack,
downStack: mockDownStack,
}),
@@ -146,6 +149,7 @@ describe('self stack lifecycle refusal', () => {
expect(mockDeployStack).not.toHaveBeenCalled();
expect(mockUpdateStack).not.toHaveBeenCalled();
expect(mockRunCommand).not.toHaveBeenCalled();
expect(mockRunDown).not.toHaveBeenCalled();
expect(mockDownStack).not.toHaveBeenCalled();
expect(mockStopContainer).not.toHaveBeenCalled();
});
@@ -19,6 +19,7 @@ import * as policyGate from '../helpers/policyGate';
const {
mockDeployStack,
mockRunCommand,
mockRunDown,
mockUpdateStack,
mockGetContainersByStack,
mockRestartContainer,
@@ -33,6 +34,7 @@ const {
} = vi.hoisted(() => ({
mockDeployStack: vi.fn(),
mockRunCommand: vi.fn(),
mockRunDown: vi.fn(),
mockUpdateStack: vi.fn(),
mockGetContainersByStack: vi.fn(),
mockRestartContainer: vi.fn(),
@@ -57,6 +59,7 @@ vi.mock('../services/ComposeService', async () => {
getInstance: () => ({
deployStack: mockDeployStack,
runCommand: mockRunCommand,
runDown: mockRunDown,
updateStack: mockUpdateStack,
}),
},
@@ -140,6 +143,7 @@ afterAll(() => {
beforeEach(() => {
mockDeployStack.mockReset();
mockRunCommand.mockReset();
mockRunDown.mockReset();
mockUpdateStack.mockReset();
mockGetContainersByStack.mockReset();
mockRestartContainer.mockReset();
@@ -388,8 +392,8 @@ describe('post-deploy scan opt-out', () => {
});
describe('deploy_failure notification on /down error', () => {
it('dispatches deploy_failure alert when runCommand (down) throws', async () => {
mockRunCommand.mockRejectedValue(new Error('container removal error'));
it('dispatches deploy_failure alert when runDown throws', async () => {
mockRunDown.mockRejectedValue(new Error('container removal error'));
const res = await request(app)
.post('/api/stacks/myapp/down')
+26 -28
View File
@@ -2,53 +2,51 @@ import { NodeRegistry } from '../services/NodeRegistry';
import { CROSS_NODE_RBAC_CAPABILITY } from '../services/CapabilityRegistry';
import { getErrorMessage } from '../utils/errors';
// In-flight probes deduped per node so a burst of concurrent gated requests
// shares one /api/meta round-trip. The entry is dropped as soon as it settles,
// so the NEXT request re-probes. The verdict is deliberately NOT cached across
// requests: a remote can be replaced by older code at the same URL (a rollback
// or image pin), and a stale "supported" verdict would reopen the cross-node
// escalation, so each gated action re-verifies against the live remote.
const inFlight = new Map<number, Promise<boolean>>();
// In-flight probes deduped per node+capability so concurrent checks for
// different capabilities on the same node cannot share the wrong boolean.
const inFlight = new Map<string, Promise<boolean>>();
function probeKey(nodeId: number, capability: string): string {
return `${nodeId}:${capability}`;
}
/**
* Whether a remote node advertises that it enforces cross-node RBAC: the
* forwarded actor role on HTTP requests and the exact-stack allowlist on
* stop-by-label. Probes the remote's live /api/meta on every call (concurrent
* calls for the same node share one probe).
* Whether a remote node advertises a given capability. Probes the remote's live
* /api/meta on every call (concurrent calls for the same node+capability share
* one probe).
*
* Fails closed: a remote that does not advertise the capability, that cannot be
* read (offline/unreachable, which yields empty capabilities), or that errors
* is treated as unsupported, so the caller denies rather than risk escalating a
* non-admin request or over-stopping. Because the probe is live, a remote
* downgraded to older code is detected on the next gated action rather than
* trusted until a cache expires.
* Fails closed: unsupported, offline, or unreachable remotes return false.
*/
export async function remoteSupportsCrossNodeRbac(nodeId: number): Promise<boolean> {
const existing = inFlight.get(nodeId);
export async function remoteAdvertisesCapability(nodeId: number, capability: string): Promise<boolean> {
const key = probeKey(nodeId, capability);
const existing = inFlight.get(key);
if (existing) return existing;
const probe = (async (): Promise<boolean> => {
try {
const meta = await NodeRegistry.getInstance().fetchMetaForNode(nodeId);
// Check the advertised capability directly. An offline/unreadable remote
// yields OFFLINE_META with empty capabilities, so this already fails
// closed; keying off the capability (not the version) also correctly
// trusts a reachable remote whose version string is non-semver, e.g. a
// 0.0.0-dev image, but that genuinely advertises the capability.
return meta.capabilities.includes(CROSS_NODE_RBAC_CAPABILITY);
return meta.capabilities.includes(capability);
} catch (err) {
console.warn(
`[CrossNodeRBAC] Could not verify capability for node ${nodeId}; treating as unsupported:`,
`[RemoteCapability] Could not verify "${capability}" for node ${nodeId}; treating as unsupported:`,
getErrorMessage(err, 'unknown'),
);
return false;
}
})();
inFlight.set(nodeId, probe);
inFlight.set(key, probe);
try {
return await probe;
} finally {
inFlight.delete(nodeId);
inFlight.delete(key);
}
}
/**
* Whether a remote node advertises cross-node RBAC enforcement for proxied
* requests. Thin wrapper over {@link remoteAdvertisesCapability}.
*/
export async function remoteSupportsCrossNodeRbac(nodeId: number): Promise<boolean> {
return remoteAdvertisesCapability(nodeId, CROSS_NODE_RBAC_CAPABILITY);
}
+33 -26
View File
@@ -4,7 +4,8 @@ import { NodeRegistry } from '../services/NodeRegistry';
import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER } from '../services/license-headers';
import { LicenseService } from '../services/LicenseService';
import { isProxyExemptPath } from '../helpers/proxyExemptPaths';
import { remoteSupportsCrossNodeRbac } from '../helpers/remoteCapabilities';
import { remoteSupportsCrossNodeRbac, remoteAdvertisesCapability } from '../helpers/remoteCapabilities';
import { STACK_DOWN_REMOVE_VOLUMES_CAPABILITY } from '../services/CapabilityRegistry';
import { getErrorMessage } from '../utils/errors';
import { DatabaseService } from '../services/DatabaseService';
import { redactSensitiveText } from '../utils/safeLog';
@@ -146,31 +147,37 @@ export function createRemoteProxyMiddleware(): RequestHandler {
return;
}
// Mixed-version RBAC gate. The forwarded actor role is enforced only by a
// remote that advertises cross-node-rbac; an older remote ignores the
// header and runs the proxied request as admin. So a non-admin must not be
// forwarded to a remote that does not advertise the capability. Admins are
// unaffected (they are admin on the remote regardless), and the check is
// skipped for them so it never adds latency to the admin path. Fails closed
// when the capability cannot be determined. Using `?.` so an unresolved user
// (not reachable past authGate, but defensive) is gated, never waved through.
if (req.user?.role !== 'admin') {
remoteSupportsCrossNodeRbac(req.nodeId)
.then((supported) => {
if (!supported) {
res.status(403).json({
error: `Remote node "${node.name}" is running a version that does not enforce per-user permissions. Upgrade it before non-admin users can act on it.`,
});
return;
}
req.proxyTarget = target;
proxy(req, res, next);
})
.catch(next);
return;
}
const runGatedProxy = async (): Promise<void> => {
if (isStackDownWithRemoveVolumes(req)) {
const supported = await remoteAdvertisesCapability(req.nodeId, STACK_DOWN_REMOVE_VOLUMES_CAPABILITY);
if (!supported) {
res.status(400).json({ error: 'Volume removal is not supported on this node' });
return;
}
}
req.proxyTarget = target;
proxy(req, res, next);
// Mixed-version RBAC gate (non-admin only).
if (req.user?.role !== 'admin') {
const rbacSupported = await remoteSupportsCrossNodeRbac(req.nodeId);
if (!rbacSupported) {
res.status(403).json({
error: `Remote node "${node.name}" is running a version that does not enforce per-user permissions. Upgrade it before non-admin users can act on it.`,
});
return;
}
}
req.proxyTarget = target;
proxy(req, res, next);
};
runGatedProxy().catch(next);
};
}
/** POST /stacks/:stackName/down with ?removeVolumes=true (path is post-/api strip). */
function isStackDownWithRemoveVolumes(req: Request): boolean {
if (req.method !== 'POST') return false;
if (!/^\/stacks\/[^/]+\/down$/.test(req.path)) return false;
return req.query.removeVolumes === 'true';
}
+15 -3
View File
@@ -56,6 +56,7 @@ import { resolveStackEnvSources, discoverStackLocalEnvFiles } from '../helpers/e
import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants';
import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
import { isSelfStack, refuseIfSelfStack, selfStackProtectedBulkResult } from '../helpers/selfStackGuard';
import { getActiveCapabilities, STACK_DOWN_REMOVE_VOLUMES_CAPABILITY } from '../services/CapabilityRegistry';
// Authenticated users with edit permission can write arbitrarily large compose
// files. Refuse to YAML.parse anything beyond this bound so a malformed (or
@@ -86,7 +87,7 @@ function notifyActionSuccess(category: NotificationCategory, message: string, st
const STACK_OP_PRESENT_PARTICIPLE: Record<StackOpAction, string> = {
deploy: 'deploying',
down: 'stopping',
down: 'taking down',
restart: 'restarting',
stop: 'stopping',
start: 'starting',
@@ -1664,11 +1665,22 @@ stacksRouter.post('/:stackName/down', async (req: Request, res: Response) => {
if (!tryAcquireStackOpLock(req, res, stackName, 'down')) return;
const t0 = Date.now();
let ok = false;
const removeVolumes = req.query.removeVolumes === 'true';
try {
if (isDebugEnabled()) console.debug(`[Stacks:debug] Down starting`, { stackName: sanitizeForLog(stackName), nodeId: req.nodeId });
await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', getTerminalWs(req.get(DEPLOY_SESSION_HEADER)));
if (removeVolumes && !getActiveCapabilities().includes(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY)) {
res.status(400).json({ error: 'Volume removal is not supported on this node' });
return;
}
if (isDebugEnabled()) console.debug(`[Stacks:debug] Down starting`, { stackName: sanitizeForLog(stackName), nodeId: req.nodeId, removeVolumes });
await ComposeService.getInstance(req.nodeId).runDown(stackName, { removeVolumes }, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)));
invalidateNodeCaches(req.nodeId);
dlog(`[Stacks] Down completed: ${sanitizeForLog(stackName)}`);
notifyActionSuccess(
'stack_taken_down',
`${stackName} taken down${removeVolumes ? ' (volumes removed)' : ''}`,
stackName,
req.user?.username ?? 'system',
);
ok = true;
res.json({ status: 'Command started' });
} catch (error: unknown) {
@@ -54,6 +54,7 @@ export const CAPABILITIES = [
'project-env-files',
'compose-storage',
'cross-node-rbac',
'stack-down-remove-volumes',
] as const;
/**
@@ -67,6 +68,9 @@ export const CROSS_NODE_RBAC_CAPABILITY = 'cross-node-rbac';
export type Capability = (typeof CAPABILITIES)[number];
/** Capability for optional `?removeVolumes=true` on POST /stacks/:name/down. */
export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' as const satisfies Capability;
/** Returns true when the string is a usable semver version. */
export function isValidVersion(v: string | null | undefined): v is string {
return !!v && v !== 'unknown' && v !== '0.0.0-dev' && !!semver.valid(v);
+7
View File
@@ -390,6 +390,13 @@ export class ComposeService {
await this.execute('docker', await this.authoredComposeArgs(stackName, [action]), stackDir, ws);
}
/** Interactive compose down (Take down UI / POST /down). Plain `down` by default. */
async runDown(stackName: string, options?: { removeVolumes?: boolean }, ws?: WebSocket): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
const args = options?.removeVolumes ? ['down', '--volumes'] : ['down'];
await this.execute('docker', await this.authoredComposeArgs(stackName, args), stackDir, ws);
}
/**
* Opt-in guard: when `env_block_deploy_on_missing_required` is enabled, refuse a
* deploy whose required `${VAR:?err}` variables are unset OR empty, before any
+2 -1
View File
@@ -19,6 +19,7 @@ export type NotificationCategory =
| 'stack_started'
| 'stack_stopped'
| 'stack_restarted'
| 'stack_taken_down'
| 'image_update_available'
| 'image_update_applied'
| 'autoheal_triggered'
@@ -43,7 +44,7 @@ export type NotificationCategory =
export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [
'deploy_success', 'deploy_failure', 'stack_started', 'stack_stopped',
'stack_restarted', 'image_update_available', 'image_update_applied',
'stack_restarted', 'stack_taken_down', 'image_update_available', 'image_update_applied',
'autoheal_triggered', 'monitor_alert', 'scan_finding',
'blueprint_deployed', 'blueprint_deployment_failed',
'blueprint_drift_detected', 'blueprint_drift_correction_failed',
+1 -1
View File
@@ -14,7 +14,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
// Stack lifecycle
'POST /stacks/*/deploy': 'Deployed stack',
'POST /stacks/*/down': 'Stopped stack',
'POST /stacks/*/down': 'Took stack down',
'POST /stacks/*/start': 'Started stack',
'POST /stacks/*/stop': 'Stopped stack',
'POST /stacks/*/restart': 'Restarted stack',