diff --git a/backend/src/__tests__/self-identity-service.test.ts b/backend/src/__tests__/self-identity-service.test.ts new file mode 100644 index 00000000..e11c4b2f --- /dev/null +++ b/backend/src/__tests__/self-identity-service.test.ts @@ -0,0 +1,273 @@ +/** + * Unit tests for SelfIdentityService. Covers happy-path inspect, missing + * HOSTNAME (dev mode), Dockerode 404 (also dev mode), and the isOwn* matchers. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ── Hoisted mocks ────────────────────────────────────────────────────── + +const { mockContainer, mockDocker } = vi.hoisted(() => { + const mockContainer = { + inspect: vi.fn(), + }; + const mockDocker = { + getContainer: vi.fn(() => mockContainer), + listImages: vi.fn().mockResolvedValue([]), + listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }), + listNetworks: vi.fn().mockResolvedValue([]), + listContainers: vi.fn().mockResolvedValue([]), + }; + return { mockContainer, mockDocker }; +}); + +vi.mock('../services/NodeRegistry', () => ({ + NodeRegistry: { + getInstance: () => ({ + getDocker: () => mockDocker, + getDefaultNodeId: () => 1, + }), + }, +})); + +vi.mock('child_process', () => ({ exec: vi.fn() })); +vi.mock('util', () => ({ promisify: () => vi.fn() })); + +import SelfIdentityService from '../services/SelfIdentityService'; + +const FULL_CONTAINER_ID = 'a'.repeat(64); +const FULL_IMAGE_ID_HEX = 'b'.repeat(64); +const FULL_NETWORK_ID = 'c'.repeat(64); + +const originalHostname = process.env.HOSTNAME; + +beforeEach(() => { + vi.clearAllMocks(); + // mockReset (not clearAllMocks) drops implementations set by prior + // mockResolvedValue/mockResolvedValueOnce calls, otherwise resolutions + // leak across tests. + mockContainer.inspect.mockReset(); + SelfIdentityService.getInstance().resetForTesting(); +}); + +afterEach(() => { + // restoreAllMocks() puts vi.spyOn'd statics (readContainerIdFromCgroup, + // console.warn) back to their real implementations so the next test + // exercises real code paths. + vi.restoreAllMocks(); + if (originalHostname === undefined) delete process.env.HOSTNAME; + else process.env.HOSTNAME = originalHostname; +}); + +describe('SelfIdentityService.initialize', () => { + it('populates identity when HOSTNAME is set and inspect resolves', async () => { + process.env.HOSTNAME = 'sencho-1'; + mockContainer.inspect.mockResolvedValue({ + Id: FULL_CONTAINER_ID, + Name: '/sencho', + Image: 'sha256:' + FULL_IMAGE_ID_HEX, + NetworkSettings: { + Networks: { + sencho_mesh: { NetworkID: FULL_NETWORK_ID }, + }, + }, + Mounts: [ + { Type: 'volume', Name: 'sencho_data' }, + { Type: 'bind', Source: '/host/path', Destination: '/app/compose' }, + ], + }); + + const svc = SelfIdentityService.getInstance(); + await svc.initialize(); + + const id = svc.getIdentity(); + expect(id.containerId).toBe(FULL_CONTAINER_ID); + expect(id.containerName).toBe('sencho'); + expect(id.imageId).toBe(FULL_IMAGE_ID_HEX); + expect(id.networkNames).toEqual(['sencho_mesh']); + expect(id.volumeNames).toEqual(['sencho_data']); + }); + + it('stays empty when HOSTNAME is unset (dev mode)', async () => { + delete process.env.HOSTNAME; + const svc = SelfIdentityService.getInstance(); + await svc.initialize(); + expect(svc.getIdentity().containerId).toBeNull(); + expect(svc.getIdentity().imageId).toBeNull(); + expect(mockContainer.inspect).not.toHaveBeenCalled(); + }); + + it('stays empty when HOSTNAME inspect 404s and cgroup probe has no container ID (dev mode)', async () => { + process.env.HOSTNAME = 'my-laptop'; + const err: Error & { statusCode?: number } = Object.assign(new Error('no such container'), { statusCode: 404 }); + mockContainer.inspect.mockRejectedValue(err); + vi.spyOn(SelfIdentityService, 'readContainerIdFromCgroup').mockResolvedValue(null); + + const svc = SelfIdentityService.getInstance(); + await svc.initialize(); + expect(svc.getIdentity().containerId).toBeNull(); + }); + + it('falls back to /proc/self/cgroup when HOSTNAME inspect 404s (custom --hostname)', async () => { + process.env.HOSTNAME = 'my-custom-name'; + const err404: Error & { statusCode?: number } = Object.assign(new Error('no such container'), { statusCode: 404 }); + // First call (HOSTNAME) 404s; second call (cgroup-resolved ID) succeeds. + mockContainer.inspect + .mockRejectedValueOnce(err404) + .mockResolvedValueOnce({ + Id: FULL_CONTAINER_ID, + Name: '/sencho', + Image: 'sha256:' + FULL_IMAGE_ID_HEX, + NetworkSettings: { Networks: { sencho_mesh: { NetworkID: FULL_NETWORK_ID } } }, + Mounts: [{ Type: 'volume', Name: 'sencho_data' }], + }); + vi.spyOn(SelfIdentityService, 'readContainerIdFromCgroup').mockResolvedValue(FULL_CONTAINER_ID); + + const svc = SelfIdentityService.getInstance(); + await svc.initialize(); + expect(svc.getIdentity().containerId).toBe(FULL_CONTAINER_ID); + expect(mockDocker.getContainer).toHaveBeenNthCalledWith(1, 'my-custom-name'); + expect(mockDocker.getContainer).toHaveBeenNthCalledWith(2, FULL_CONTAINER_ID); + }); + + it('stays empty when HOSTNAME 404s and cgroup probe resolves but inspect 404s on that ID too', async () => { + process.env.HOSTNAME = 'sencho-1'; + const err404: Error & { statusCode?: number } = Object.assign(new Error('no such container'), { statusCode: 404 }); + mockContainer.inspect.mockRejectedValue(err404); + vi.spyOn(SelfIdentityService, 'readContainerIdFromCgroup').mockResolvedValue(FULL_CONTAINER_ID); + + const svc = SelfIdentityService.getInstance(); + await svc.initialize(); + expect(svc.getIdentity().containerId).toBeNull(); + }); + + it('stays empty and logs on non-404 inspect failure', async () => { + process.env.HOSTNAME = 'sencho-1'; + mockContainer.inspect.mockRejectedValue(new Error('docker daemon unreachable')); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const svc = SelfIdentityService.getInstance(); + await svc.initialize(); + expect(svc.getIdentity().containerId).toBeNull(); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('parses /proc/self/cgroup formats (cgroupv1 docker, cgroupv2 docker, podman libpod)', async () => { + const tmp = await import('os'); + const fsp = await import('fs/promises'); + const path = await import('path'); + const dir = await fsp.mkdtemp(path.join(tmp.tmpdir(), 'sencho-cgroup-')); + + const cgroupV1 = path.join(dir, 'cgv1'); + await fsp.writeFile(cgroupV1, `12:cpuset:/docker/${FULL_CONTAINER_ID}\n11:memory:/docker/${FULL_CONTAINER_ID}\n`); + expect(await SelfIdentityService.readContainerIdFromCgroup(cgroupV1)).toBe(FULL_CONTAINER_ID); + + const cgroupV2 = path.join(dir, 'cgv2'); + await fsp.writeFile(cgroupV2, `0::/system.slice/docker-${FULL_CONTAINER_ID}.scope\n`); + expect(await SelfIdentityService.readContainerIdFromCgroup(cgroupV2)).toBe(FULL_CONTAINER_ID); + + const podman = path.join(dir, 'podman'); + await fsp.writeFile(podman, `0::/user.slice/user-1000.slice/libpod-${FULL_CONTAINER_ID}.scope\n`); + expect(await SelfIdentityService.readContainerIdFromCgroup(podman)).toBe(FULL_CONTAINER_ID); + + const noMatch = path.join(dir, 'empty'); + await fsp.writeFile(noMatch, '0::/system.slice/sshd.service\n'); + expect(await SelfIdentityService.readContainerIdFromCgroup(noMatch)).toBeNull(); + + expect(await SelfIdentityService.readContainerIdFromCgroup(path.join(dir, 'does-not-exist'))).toBeNull(); + + await fsp.rm(dir, { recursive: true, force: true }); + }); + + it('is idempotent: re-initialize is a no-op', async () => { + process.env.HOSTNAME = 'sencho-1'; + mockContainer.inspect.mockResolvedValue({ + Id: FULL_CONTAINER_ID, + Name: '/sencho', + Image: 'sha256:' + FULL_IMAGE_ID_HEX, + NetworkSettings: { Networks: {} }, + Mounts: [], + }); + + const svc = SelfIdentityService.getInstance(); + await svc.initialize(); + await svc.initialize(); + expect(mockContainer.inspect).toHaveBeenCalledTimes(1); + }); +}); + +describe('SelfIdentityService matchers', () => { + beforeEach(async () => { + process.env.HOSTNAME = 'sencho-1'; + mockContainer.inspect.mockResolvedValue({ + Id: FULL_CONTAINER_ID, + Name: '/sencho', + Image: 'sha256:' + FULL_IMAGE_ID_HEX, + NetworkSettings: { + Networks: { sencho_mesh: { NetworkID: FULL_NETWORK_ID } }, + }, + Mounts: [{ Type: 'volume', Name: 'sencho_data' }], + }); + SelfIdentityService.getInstance().resetForTesting(); + await SelfIdentityService.getInstance().initialize(); + }); + + it('isOwnImage matches full sha256 ref, hex-only, and short prefix', () => { + const svc = SelfIdentityService.getInstance(); + expect(svc.isOwnImage('sha256:' + FULL_IMAGE_ID_HEX)).toBe(true); + expect(svc.isOwnImage(FULL_IMAGE_ID_HEX)).toBe(true); + expect(svc.isOwnImage(FULL_IMAGE_ID_HEX.slice(0, 12))).toBe(true); + expect(svc.isOwnImage('d'.repeat(64))).toBe(false); + }); + + it('isOwnImage does not match repo:tag strings (callers should pass IDs)', () => { + const svc = SelfIdentityService.getInstance(); + expect(svc.isOwnImage('ghcr.io/studio-saelix/sencho:latest')).toBe(false); + expect(svc.isOwnImage('saelix/sencho:1.0')).toBe(false); + }); + + it('isOwnNetwork matches by ID and by name', () => { + const svc = SelfIdentityService.getInstance(); + expect(svc.isOwnNetwork(FULL_NETWORK_ID)).toBe(true); + expect(svc.isOwnNetwork(FULL_NETWORK_ID.slice(0, 12))).toBe(true); + expect(svc.isOwnNetwork('sencho_mesh')).toBe(true); + expect(svc.isOwnNetwork('bridge')).toBe(false); + }); + + it('isOwnNetwork does not falsely match a non-hex network name that overlaps a cached ID prefix', () => { + // The cached network ID is 64 chars of 'c'. A network named "ccc" is NOT + // a hex ID input (length below 12), so prefix matching must not fire. + const svc = SelfIdentityService.getInstance(); + expect(svc.isOwnNetwork('ccc')).toBe(false); + expect(svc.isOwnNetwork('cc')).toBe(false); + }); + + it('isOwnVolume matches by name only', () => { + const svc = SelfIdentityService.getInstance(); + expect(svc.isOwnVolume('sencho_data')).toBe(true); + expect(svc.isOwnVolume('other_volume')).toBe(false); + }); + + it('isOwnContainer matches full, short prefix, and name', () => { + const svc = SelfIdentityService.getInstance(); + expect(svc.isOwnContainer(FULL_CONTAINER_ID)).toBe(true); + expect(svc.isOwnContainer(FULL_CONTAINER_ID.slice(0, 12))).toBe(true); + expect(svc.isOwnContainer('sencho')).toBe(true); + expect(svc.isOwnContainer('e'.repeat(64))).toBe(false); + }); +}); + +describe('SelfIdentityService matchers when empty (dev mode)', () => { + beforeEach(async () => { + delete process.env.HOSTNAME; + SelfIdentityService.getInstance().resetForTesting(); + await SelfIdentityService.getInstance().initialize(); + }); + + it('returns false for every isOwn* call so today\'s behavior is preserved', () => { + const svc = SelfIdentityService.getInstance(); + expect(svc.isOwnImage('sha256:' + FULL_IMAGE_ID_HEX)).toBe(false); + expect(svc.isOwnNetwork('sencho_mesh')).toBe(false); + expect(svc.isOwnVolume('sencho_data')).toBe(false); + expect(svc.isOwnContainer(FULL_CONTAINER_ID)).toBe(false); + }); +}); diff --git a/backend/src/__tests__/system-maintenance-self-protect.test.ts b/backend/src/__tests__/system-maintenance-self-protect.test.ts new file mode 100644 index 00000000..afcec6bc --- /dev/null +++ b/backend/src/__tests__/system-maintenance-self-protect.test.ts @@ -0,0 +1,204 @@ +/** + * Route-level tests for self-protection. Stubs SelfIdentityService matchers + * to simulate "this is Sencho's own resource" and verifies the delete routes + * return 423 Locked, plus that /prune/orphans filters self out silently. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +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 authHeader: string; +let SelfIdentityService: typeof import('../services/SelfIdentityService').default; +let DockerController: typeof import('../services/DockerController').default; + +const SELF_IMAGE = 'a'.repeat(64); +const SELF_NETWORK = 'b'.repeat(64); +const SELF_CONTAINER = 'c'.repeat(64); +const OTHER_IMAGE = 'd'.repeat(64); +const OTHER_NETWORK = 'e'.repeat(64); +const OTHER_CONTAINER = 'f'.repeat(64); + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ default: SelfIdentityService } = await import('../services/SelfIdentityService')); + ({ default: DockerController } = await import('../services/DockerController')); + const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); + authHeader = `Bearer ${token}`; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +afterEach(() => { + vi.restoreAllMocks(); + SelfIdentityService.getInstance().resetForTesting(); +}); + +function stubSelfIdentity(opts: { imageId?: string; networkId?: string; containerId?: string; volumeName?: string }) { + const svc = SelfIdentityService.getInstance(); + vi.spyOn(svc, 'isOwnImage').mockImplementation((id: string) => id === opts.imageId); + vi.spyOn(svc, 'isOwnNetwork').mockImplementation((id: string) => id === opts.networkId); + vi.spyOn(svc, 'isOwnContainer').mockImplementation((id: string) => id === opts.containerId); + vi.spyOn(svc, 'isOwnVolume').mockImplementation((id: string) => id === opts.volumeName); +} + +function stubDockerControllerNoops() { + const fake = { + removeImage: vi.fn().mockResolvedValue(undefined), + removeNetwork: vi.fn().mockResolvedValue(undefined), + removeVolume: vi.fn().mockResolvedValue(undefined), + removeContainers: vi.fn().mockResolvedValue([]), + }; + vi.spyOn(DockerController, 'getInstance').mockReturnValue(fake as unknown as ReturnType); + return fake; +} + +describe('Self-protection on /api/system delete routes', () => { + it('refuses to delete Sencho\'s own image with 423 Locked', async () => { + stubSelfIdentity({ imageId: SELF_IMAGE }); + const docker = stubDockerControllerNoops(); + + const res = await request(app) + .post('/api/system/images/delete') + .set('Authorization', authHeader) + .send({ id: SELF_IMAGE }); + + expect(res.status).toBe(423); + expect(res.body.error).toMatch(/Cannot delete the running Sencho instance/); + expect(res.body.kind).toBe('image'); + expect(docker.removeImage).not.toHaveBeenCalled(); + }); + + it('allows deleting other images', async () => { + stubSelfIdentity({ imageId: SELF_IMAGE }); + const docker = stubDockerControllerNoops(); + + const res = await request(app) + .post('/api/system/images/delete') + .set('Authorization', authHeader) + .send({ id: OTHER_IMAGE }); + + expect(res.status).toBe(200); + expect(docker.removeImage).toHaveBeenCalledWith(OTHER_IMAGE); + }); + + it('accepts sha256:-prefixed image IDs (the form Docker returns from /system/images)', async () => { + stubSelfIdentity({ imageId: SELF_IMAGE }); + const docker = stubDockerControllerNoops(); + + const res = await request(app) + .post('/api/system/images/delete') + .set('Authorization', authHeader) + .send({ id: 'sha256:' + OTHER_IMAGE }); + + expect(res.status).toBe(200); + expect(docker.removeImage).toHaveBeenCalledWith('sha256:' + OTHER_IMAGE); + }); + + it('refuses to delete Sencho\'s own network with 423', async () => { + stubSelfIdentity({ networkId: SELF_NETWORK }); + const docker = stubDockerControllerNoops(); + + const res = await request(app) + .post('/api/system/networks/delete') + .set('Authorization', authHeader) + .send({ id: SELF_NETWORK }); + + expect(res.status).toBe(423); + expect(res.body.kind).toBe('network'); + expect(docker.removeNetwork).not.toHaveBeenCalled(); + }); + + it('allows deleting other networks', async () => { + stubSelfIdentity({ networkId: SELF_NETWORK }); + const docker = stubDockerControllerNoops(); + + const res = await request(app) + .post('/api/system/networks/delete') + .set('Authorization', authHeader) + .send({ id: OTHER_NETWORK }); + + expect(res.status).toBe(200); + expect(docker.removeNetwork).toHaveBeenCalledWith(OTHER_NETWORK); + }); + + it('refuses to delete Sencho\'s own volume with 423', async () => { + stubSelfIdentity({ volumeName: 'sencho_data' }); + const docker = stubDockerControllerNoops(); + + const res = await request(app) + .post('/api/system/volumes/delete') + .set('Authorization', authHeader) + .send({ id: 'sencho_data' }); + + expect(res.status).toBe(423); + expect(res.body.kind).toBe('volume'); + expect(docker.removeVolume).not.toHaveBeenCalled(); + }); + + it('allows deleting other volumes', async () => { + stubSelfIdentity({ volumeName: 'sencho_data' }); + const docker = stubDockerControllerNoops(); + + const res = await request(app) + .post('/api/system/volumes/delete') + .set('Authorization', authHeader) + .send({ id: 'other_volume' }); + + expect(res.status).toBe(200); + expect(docker.removeVolume).toHaveBeenCalledWith('other_volume'); + }); +}); + +describe('Self-protection on /api/system/prune/orphans', () => { + it('filters Sencho\'s own container out silently and reports skipped:self', async () => { + stubSelfIdentity({ containerId: SELF_CONTAINER }); + const docker = stubDockerControllerNoops(); + docker.removeContainers.mockResolvedValue([{ id: OTHER_CONTAINER, success: true }]); + + const res = await request(app) + .post('/api/system/prune/orphans') + .set('Authorization', authHeader) + .send({ containerIds: [SELF_CONTAINER, OTHER_CONTAINER] }); + + expect(res.status).toBe(200); + expect(res.body.skipped).toBe('self'); + expect(docker.removeContainers).toHaveBeenCalledWith([OTHER_CONTAINER]); + }); + + it('does not flag a request that excludes the self container', async () => { + stubSelfIdentity({ containerId: SELF_CONTAINER }); + const docker = stubDockerControllerNoops(); + docker.removeContainers.mockResolvedValue([{ id: OTHER_CONTAINER, success: true }]); + + const res = await request(app) + .post('/api/system/prune/orphans') + .set('Authorization', authHeader) + .send({ containerIds: [OTHER_CONTAINER] }); + + expect(res.status).toBe(200); + expect(res.body.skipped).toBeUndefined(); + expect(docker.removeContainers).toHaveBeenCalledWith([OTHER_CONTAINER]); + }); +}); + +describe('Self-protection in dev mode (SelfIdentityService empty)', () => { + it('deletes any image when no self identity is configured', async () => { + // No stubbing of isOwn*; the reset in afterEach left the service empty + // and a real isOwnImage on an empty cache returns false. + SelfIdentityService.getInstance().resetForTesting(); + const docker = stubDockerControllerNoops(); + + const res = await request(app) + .post('/api/system/images/delete') + .set('Authorization', authHeader) + .send({ id: SELF_IMAGE }); + + expect(res.status).toBe(200); + expect(docker.removeImage).toHaveBeenCalledWith(SELF_IMAGE); + }); +}); + diff --git a/backend/src/bootstrap/startup.ts b/backend/src/bootstrap/startup.ts index 2dba69ab..c98b672f 100644 --- a/backend/src/bootstrap/startup.ts +++ b/backend/src/bootstrap/startup.ts @@ -6,6 +6,7 @@ import { NodeRegistry } from '../services/NodeRegistry'; import { DatabaseService } from '../services/DatabaseService'; import { LicenseService } from '../services/LicenseService'; import SelfUpdateService from '../services/SelfUpdateService'; +import SelfIdentityService from '../services/SelfIdentityService'; import { MonitorService } from '../services/MonitorService'; import { AutoHealService } from '../services/AutoHealService'; import { FleetSyncRetryService } from '../services/FleetSyncRetryService'; @@ -121,6 +122,7 @@ export async function startServer(server: Server): Promise { // so total boot time is the slowest one rather than the sum. await Promise.all([ SelfUpdateService.getInstance().initialize(), + SelfIdentityService.getInstance().initialize(), DockerEventManager.getInstance().start(), TrivyService.getInstance().initialize(), ]); diff --git a/backend/src/routes/systemMaintenance.ts b/backend/src/routes/systemMaintenance.ts index ba9ac534..c4b0d26e 100644 --- a/backend/src/routes/systemMaintenance.ts +++ b/backend/src/routes/systemMaintenance.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from 'express'; import DockerController, { type CreateNetworkOptions, type NetworkDriver } from '../services/DockerController'; import { FileSystemService } from '../services/FileSystemService'; +import SelfIdentityService from '../services/SelfIdentityService'; import { requireAdmin } from '../middleware/tierGates'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; import { isValidDockerResourceId, isValidCidr, isValidIPv4 } from '../utils/validation'; @@ -10,6 +11,24 @@ import { sanitizeForLog } from '../utils/safeLog'; export const systemMaintenanceRouter = Router(); +// 423 Locked is sent when the operator targets the running Sencho container's +// own image / volume / network. The frontend surfaces the `error` string as a +// toast; `kind` is for diagnostics. +function rejectIfSelf(kind: 'image' | 'volume' | 'network', id: string, res: Response): boolean { + const self = SelfIdentityService.getInstance(); + const matched = + (kind === 'image' && self.isOwnImage(id)) || + (kind === 'volume' && self.isOwnVolume(id)) || + (kind === 'network' && self.isOwnNetwork(id)); + if (!matched) return false; + res.status(423).json({ + error: 'Cannot delete the running Sencho instance', + kind, + id, + }); + return true; +} + systemMaintenanceRouter.get('/orphans', async (req: Request, res: Response) => { try { const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks(); @@ -33,13 +52,19 @@ systemMaintenanceRouter.post('/prune/orphans', async (req: Request, res: Respons if (invalidIds.length > 0) { return res.status(400).json({ error: 'One or more container IDs have an invalid format' }); } - console.log(`[Resources] Prune orphans: ${sanitizeForLog(containerIds.length)} container(s) requested`); + // Silently drop the running Sencho container if a stale client somehow + // includes it in the prune set. The Unmanaged tab already filters self + // out, so this is a belt-and-braces guard. + const self = SelfIdentityService.getInstance(); + const skippedSelf = (containerIds as string[]).some((id) => self.isOwnContainer(id)); + const safeIds: string[] = (containerIds as string[]).filter((id) => !self.isOwnContainer(id)); + console.log(`[Resources] Prune orphans: ${sanitizeForLog(safeIds.length)} container(s) requested${skippedSelf ? ' (self skipped)' : ''}`); const dockerController = DockerController.getInstance(req.nodeId); - const results = await dockerController.removeContainers(containerIds); + const results = await dockerController.removeContainers(safeIds); const succeeded = results.filter((r: { success: boolean }) => r.success).length; - console.log(`[Resources] Prune orphans completed: ${succeeded}/${sanitizeForLog(containerIds.length)} removed`); + console.log(`[Resources] Prune orphans completed: ${succeeded}/${sanitizeForLog(safeIds.length)} removed`); invalidateNodeCaches(req.nodeId); - res.json({ results }); + res.json(skippedSelf ? { results, skipped: 'self' } : { results }); } catch (error) { console.error('Failed to prune orphan containers:', error); res.status(500).json({ error: 'Failed to prune orphan containers' }); @@ -216,10 +241,18 @@ systemMaintenanceRouter.post('/images/delete', async (req: Request, res: Respons try { const { id } = req.body; if (!id) return res.status(400).json({ error: 'ID is required' }); - if (typeof id !== 'string' || !isValidDockerResourceId(id)) { + if (typeof id !== 'string') { return res.status(400).json({ error: 'Invalid image ID format' }); } - console.log(`[Resources] Delete image: ${id.substring(0, 12)}`); + // Docker image IDs round-trip as `sha256:` through /system/images, + // so the UI and any client that forwards the same value sees the prefixed + // form. Strip before validation, mirroring the inspect route above. + const hexId = id.startsWith('sha256:') ? id.slice('sha256:'.length) : id; + if (!isValidDockerResourceId(hexId)) { + return res.status(400).json({ error: 'Invalid image ID format' }); + } + if (rejectIfSelf('image', id, res)) return; + console.log(`[Resources] Delete image: ${hexId.substring(0, 12)}`); const dockerController = DockerController.getInstance(req.nodeId); await dockerController.removeImage(id); invalidateNodeCaches(req.nodeId); @@ -235,6 +268,7 @@ systemMaintenanceRouter.post('/volumes/delete', async (req: Request, res: Respon try { const { id } = req.body; if (!id || typeof id !== 'string') return res.status(400).json({ error: 'Volume name is required' }); + if (rejectIfSelf('volume', id, res)) return; console.log(`[Resources] Delete volume: ${sanitizeForLog(id)}`); const dockerController = DockerController.getInstance(req.nodeId); await dockerController.removeVolume(id); @@ -254,6 +288,7 @@ systemMaintenanceRouter.post('/networks/delete', async (req: Request, res: Respo if (typeof id !== 'string' || !isValidDockerResourceId(id)) { return res.status(400).json({ error: 'Invalid network ID format' }); } + if (rejectIfSelf('network', id, res)) return; console.log(`[Resources] Delete network: ${id.substring(0, 12)}`); const dockerController = DockerController.getInstance(req.nodeId); await dockerController.removeNetwork(id); diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 9a498235..8c1bd398 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -8,6 +8,7 @@ import * as yaml from 'yaml'; import { NodeRegistry } from './NodeRegistry'; import { CacheService } from './CacheService'; +import SelfIdentityService from './SelfIdentityService'; import { isPathWithinBase } from '../utils/validation'; import { isDebugEnabled } from '../utils/debug'; import { sanitizeForLog } from '../utils/safeLog'; @@ -42,6 +43,7 @@ export interface ClassifiedImage { Containers: number; managedBy: string | null; managedStatus: 'managed' | 'unmanaged' | 'unused'; + isSencho: boolean; } export interface PortInUseInfo { @@ -57,6 +59,7 @@ export interface ClassifiedVolume { CreatedAt: string | null; managedBy: string | null; managedStatus: 'managed' | 'unmanaged'; + isSencho: boolean; } export interface ClassifiedNetwork { @@ -66,6 +69,7 @@ export interface ClassifiedNetwork { Scope: string; managedBy: string | null; managedStatus: 'managed' | 'unmanaged' | 'system'; + isSencho: boolean; } export interface TopologyContainer { @@ -188,6 +192,12 @@ class DockerController { }; } + // Sencho's own image, networks, and named volumes are always in use by the + // running container, so Docker's server-side prune APIs (pruneContainers, + // pruneImages, pruneNetworks, pruneVolumes) skip them by definition. No + // extra self-guard is needed at this layer; the `managed` scope path goes + // through `pruneManagedOnly`, which adds an explicit self filter for + // defense-in-depth. public async pruneSystem(target: 'containers' | 'images' | 'networks' | 'volumes', labelFilter?: string) { let spaceReclaimed = 0; if (target === 'containers') { @@ -268,6 +278,8 @@ class DockerController { if (stack) imageToStack.set(c.ImageID, stack); } + const selfIdentity = SelfIdentityService.getInstance(); + const images: ClassifiedImage[] = this.validateApiData(rawImages).map((img: any) => { const stack = imageToStack.get(img.Id) ?? null; const managedStatus: ClassifiedImage['managedStatus'] = @@ -280,6 +292,7 @@ class DockerController { Containers: img.Containers ?? 0, managedBy: stack, managedStatus, + isSencho: selfIdentity.isOwnImage(img.Id), }; }); @@ -294,12 +307,13 @@ class DockerController { CreatedAt: vol.CreatedAt ?? null, managedBy: stack, managedStatus, + isSencho: selfIdentity.isOwnVolume(vol.Name), }; }); const networks: ClassifiedNetwork[] = this.validateApiData(rawNetworks).map((net: any) => { if (DockerController.SYSTEM_NETWORKS.has(net.Name)) { - return { Id: net.Id, Name: net.Name, Driver: net.Driver, Scope: net.Scope, managedBy: null, managedStatus: 'system' as const }; + return { Id: net.Id, Name: net.Name, Driver: net.Driver, Scope: net.Scope, managedBy: null, managedStatus: 'system' as const, isSencho: false }; } const stack = DockerController.resolveProjectLabel(net.Labels?.['com.docker.compose.project'], knownSet, projectToStack); const managedStatus: ClassifiedNetwork['managedStatus'] = stack ? 'managed' : 'unmanaged'; @@ -310,6 +324,7 @@ class DockerController { Scope: net.Scope, managedBy: stack, managedStatus, + isSencho: selfIdentity.isOwnNetwork(net.Id) || selfIdentity.isOwnNetwork(net.Name), }; }); @@ -326,6 +341,7 @@ class DockerController { ): Promise<{ success: boolean; reclaimedBytes: number }> { const knownSet = new Set(knownStackNames); const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames); + const selfIdentity = SelfIdentityService.getInstance(); let reclaimedBytes = 0; if (target === 'volumes') { @@ -333,7 +349,8 @@ class DockerController { const rawVolumes: any[] = (this.validateApiData(rawVolumeData)).Volumes || []; const prunable = rawVolumes.filter((v: any) => { return !!DockerController.resolveProjectLabel(v.Labels?.['com.docker.compose.project'], knownSet, projectToStack) - && (v.UsageData?.RefCount ?? 1) === 0; + && (v.UsageData?.RefCount ?? 1) === 0 + && !selfIdentity.isOwnVolume(v.Name); }); // Removals are independent and Docker handles concurrent volume // deletes; parallelize so wall time matches the slowest single @@ -349,7 +366,9 @@ class DockerController { } else if (target === 'networks') { const rawNetworks = await this.docker.listNetworks(); const prunable = (rawNetworks as any[]).filter((n: any) => { - return !!DockerController.resolveProjectLabel(n.Labels?.['com.docker.compose.project'], knownSet, projectToStack); + return !!DockerController.resolveProjectLabel(n.Labels?.['com.docker.compose.project'], knownSet, projectToStack) + && !selfIdentity.isOwnNetwork(n.Id) + && !selfIdentity.isOwnNetwork(n.Name); }); await Promise.all(prunable.map(async (net) => { try { @@ -371,7 +390,9 @@ class DockerController { } const rawImages = await this.docker.listImages({ all: false }); const prunable = (rawImages as any[]).filter((img: any) => - img.Containers === 0 && !unmanagedImageIds.has(img.Id) + img.Containers === 0 + && !unmanagedImageIds.has(img.Id) + && !selfIdentity.isOwnImage(img.Id) ); await Promise.all(prunable.map(async (img) => { try { @@ -398,6 +419,7 @@ class DockerController { ): Promise<{ reclaimableBytes: number }> { const knownSet = new Set(knownStackNames); const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames); + const selfIdentity = SelfIdentityService.getInstance(); let reclaimableBytes = 0; if (target === 'volumes') { @@ -405,7 +427,8 @@ class DockerController { const rawVolumes: any[] = (this.validateApiData(rawVolumeData)).Volumes || []; const prunable = rawVolumes.filter((v: any) => { return !!DockerController.resolveProjectLabel(v.Labels?.['com.docker.compose.project'], knownSet, projectToStack) - && (v.UsageData?.RefCount ?? 1) === 0; + && (v.UsageData?.RefCount ?? 1) === 0 + && !selfIdentity.isOwnVolume(v.Name); }); for (const vol of prunable) reclaimableBytes += vol.UsageData?.Size ?? 0; } else if (target === 'networks') { @@ -424,7 +447,9 @@ class DockerController { } const rawImages = await this.docker.listImages({ all: false }); const prunable = (rawImages as any[]).filter((img: any) => - img.Containers === 0 && !unmanagedImageIds.has(img.Id), + img.Containers === 0 + && !unmanagedImageIds.has(img.Id) + && !selfIdentity.isOwnImage(img.Id), ); for (const img of prunable) reclaimableBytes += img.Size ?? 0; } @@ -1173,11 +1198,17 @@ class DockerController { // 2. Filter and categorize orphans const orphans: Record = {}; + const selfIdentity = SelfIdentityService.getInstance(); allContainers.forEach((container) => { // Look for the docker compose project label const projectName = container.Labels?.['com.docker.compose.project']; + // Sencho's own container is not a stack on this node, so when it carries + // a compose-project label (compose-deployed installations) it would + // otherwise surface here as a stray under that project name. + if (selfIdentity.isOwnContainer(container.Id)) return; + // If it has a project label, but the project is NOT in our known list... if (projectName && !knownStackNames.includes(projectName)) { if (!orphans[projectName]) { diff --git a/backend/src/services/SelfIdentityService.ts b/backend/src/services/SelfIdentityService.ts new file mode 100644 index 00000000..429198f6 --- /dev/null +++ b/backend/src/services/SelfIdentityService.ts @@ -0,0 +1,210 @@ +import fs from 'fs/promises'; +import DockerController from './DockerController'; + +/** + * Identifies the Docker resources that belong to the running Sencho container + * (image, attached networks, named volumes, the container itself) so the + * Resources view and destructive routes can refuse to delete them and the + * Unmanaged tab can filter out Sencho's own container. + * + * Identification strategy, in order: + * 1. `process.env.HOSTNAME` resolves via `docker.getContainer(...).inspect()`. + * This is Docker's default (HOSTNAME equals the container's short ID). + * 2. `/proc/self/cgroup` fallback. Custom `--hostname`, Compose `hostname:`, + * or `--uts=host` decouples HOSTNAME from the container ID; the kernel + * still places the process in a cgroup that names the full 64-hex + * container ID for both cgroupv1 (`.../docker/`) and cgroupv2 + * (`.../docker-.scope` / `.../libpod-.scope`). + * + * In dev mode (`npm run dev` outside Docker) both paths fail, the service + * stays in its empty state, every `isOwn*()` returns false, and today's + * behavior is preserved. + */ +class SelfIdentityService { + private static instance: SelfIdentityService; + private containerId: string | null = null; + private containerName: string | null = null; + private imageIdHex: string | null = null; + private networkIds = new Set(); + private networkNames = new Set(); + private volumeNames = new Set(); + private initialized = false; + + public static getInstance(): SelfIdentityService { + if (!SelfIdentityService.instance) { + SelfIdentityService.instance = new SelfIdentityService(); + } + return SelfIdentityService.instance; + } + + async initialize(): Promise { + if (this.initialized) return; + this.initialized = true; + + const docker = DockerController.getInstance().getDocker(); + const info = await this.resolveSelfInspect(docker); + if (!info) return; + + this.containerId = info.Id ?? null; + this.containerName = (info.Name || '').replace(/^\//, '') || null; + this.imageIdHex = SelfIdentityService.stripSha(info.Image ?? '') || null; + + const nets = info.NetworkSettings?.Networks ?? {}; + for (const [name, net] of Object.entries(nets)) { + if (name) this.networkNames.add(name); + const id = (net as { NetworkID?: string } | null)?.NetworkID; + if (id) this.networkIds.add(id); + } + + const mounts = (info.Mounts ?? []) as Array<{ Type?: string; Name?: string }>; + for (const m of mounts) { + if (m.Type === 'volume' && m.Name) { + this.volumeNames.add(m.Name); + } + } + + const cidShort = this.containerId ? this.containerId.substring(0, 12) : '?'; + const iidShort = this.imageIdHex ? this.imageIdHex.substring(0, 12) : '?'; + console.log( + `[SelfIdentity] Detected self: container=${cidShort}, image=${iidShort}, ` + + `networks=${this.networkNames.size}, volumes=${this.volumeNames.size}`, + ); + } + + private async resolveSelfInspect( + docker: ReturnType, + ): Promise['inspect']>> | null> { + const hostname = process.env.HOSTNAME; + if (hostname) { + try { + return await docker.getContainer(hostname).inspect(); + } catch (err) { + const e = err as { statusCode?: number; message?: string }; + if (e?.statusCode !== 404) { + console.warn('[SelfIdentity] HOSTNAME inspect failed:', e?.message || String(err)); + return null; + } + // 404 on HOSTNAME means custom hostname or running outside Docker; + // fall through to the cgroup probe. + } + } + + const cgroupId = await SelfIdentityService.readContainerIdFromCgroup(); + if (!cgroupId) { + console.log('[SelfIdentity] no HOSTNAME match and no container ID in /proc/self/cgroup; self-protection disabled (not running in Docker?)'); + return null; + } + + try { + return await docker.getContainer(cgroupId).inspect(); + } catch (err) { + const e = err as { statusCode?: number; message?: string }; + if (e?.statusCode === 404) { + console.log('[SelfIdentity] cgroup container ID inspect returned 404; self-protection disabled'); + return null; + } + console.warn('[SelfIdentity] cgroup-resolved inspect failed:', e?.message || String(err)); + return null; + } + } + + /** True when the given container ID or name matches the running Sencho container. Accepts short or full IDs. */ + isOwnContainer(idOrName: string): boolean { + if (!idOrName) return false; + if (this.containerId && SelfIdentityService.matchesId(this.containerId, idOrName)) return true; + if (this.containerName && this.containerName === idOrName) return true; + return false; + } + + /** True when the given image reference (full or short hex ID, optionally `sha256:`-prefixed) matches Sencho's own image. */ + isOwnImage(idOrTag: string): boolean { + if (!idOrTag || !this.imageIdHex) return false; + const target = SelfIdentityService.stripSha(idOrTag); + return SelfIdentityService.matchesId(this.imageIdHex, target); + } + + /** True when the given network ID or name matches a network the Sencho container is attached to. */ + isOwnNetwork(idOrName: string): boolean { + if (!idOrName) return false; + // Names match exactly. Only hex-looking inputs (12 to 64 chars) are + // prefix-matched against the cached IDs, so a network NAMED like a hex + // prefix of Sencho's network ID is not falsely flagged. + if (this.networkNames.has(idOrName)) return true; + if (this.networkIds.has(idOrName)) return true; + if (!SelfIdentityService.isHexId(idOrName)) return false; + for (const id of this.networkIds) { + if (SelfIdentityService.matchesId(id, idOrName)) return true; + } + return false; + } + + /** True when the given volume name matches a named volume mounted into Sencho. Bind mounts are excluded by design. */ + isOwnVolume(name: string): boolean { + if (!name) return false; + return this.volumeNames.has(name); + } + + /** Diagnostic snapshot used by route handlers when composing error responses. */ + getIdentity(): { + containerId: string | null; + containerName: string | null; + imageId: string | null; + networkNames: string[]; + volumeNames: string[]; + } { + return { + containerId: this.containerId, + containerName: this.containerName, + imageId: this.imageIdHex, + networkNames: [...this.networkNames], + volumeNames: [...this.volumeNames], + }; + } + + /** Test hook: clear cached state so a fresh initialize() can run with a different stub. */ + resetForTesting(): void { + this.containerId = null; + this.containerName = null; + this.imageIdHex = null; + this.networkIds.clear(); + this.networkNames.clear(); + this.volumeNames.clear(); + this.initialized = false; + } + + private static stripSha(s: string): string { + return s.startsWith('sha256:') ? s.slice('sha256:'.length) : s; + } + + private static isHexId(s: string): boolean { + return /^[a-f0-9]{12,64}$/i.test(s); + } + + // Prefix matching is restricted to hex-shaped candidates: a 12-char short + // ID hits the cached full ID and vice versa, but a name like "bridge" never + // matches a cached ID just because of a partial overlap. + private static matchesId(full: string, candidate: string): boolean { + if (!full || !candidate) return false; + if (full === candidate) return true; + if (!SelfIdentityService.isHexId(full) || !SelfIdentityService.isHexId(candidate)) return false; + if (full.startsWith(candidate)) return true; + if (candidate.startsWith(full)) return true; + return false; + } + + // Both cgroupv1 (`12:cpuset:/docker/<64hex>`) and cgroupv2 + // (`0::/system.slice/docker-<64hex>.scope`, also podman's + // `libpod-<64hex>.scope`) embed the full container ID as a 64-hex run. + // Matching the longest such run survives kernel + runtime variation. + static async readContainerIdFromCgroup(path = '/proc/self/cgroup'): Promise { + try { + const contents = await fs.readFile(path, 'utf8'); + const matches = contents.match(/[a-f0-9]{64}/gi); + return matches && matches.length > 0 ? matches[matches.length - 1].toLowerCase() : null; + } catch { + return null; + } + } +} + +export default SelfIdentityService; diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index a97bef1f..29042f27 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -56,6 +56,10 @@ Below the hero, four tabs partition your inventory: **images**, **volumes**, **n A **Scan history** button sits on the right of the tab strip when image vulnerability scanning is configured for the node. It opens the full scan record so you can review past results without launching a new scan. See [Vulnerability scanning](/features/vulnerability-scanning) for the full workflow. + + Sencho protects its own image, network, and named volumes from accidental deletion. The matching rows carry a **Sencho** pill alongside the managed status and the delete control is disabled. + + ### Images Lists all Docker images on the host with their ID, repository tag, size, and status. @@ -193,7 +197,7 @@ By default, system networks (`bridge`, `host`, `none`) are hidden so the graph s Unmanaged tab with Select all checkbox, Purge Selected action, and a container row grouped under External Project: sencho -Lists containers running on the host that are not part of any Sencho-managed stack: containers started with `docker run`, or Compose projects outside your `COMPOSE_DIR`. The tab shows a count badge when any are detected. +Lists containers running on the host that are not part of any Sencho-managed stack: containers started with `docker run`, or Compose projects outside your `COMPOSE_DIR`. The tab shows a count badge when any are detected. The running Sencho container itself is excluded from this list so it cannot be purged by accident. Containers are grouped by their external Compose project. Each group has an **External Project** header with the project name and a container count. Each container row shows: @@ -205,3 +209,14 @@ Containers are grouped by their external Compose project. Each group has an **Ex The toolbar above the list provides a **Select all** checkbox and a destructive **Purge Selected (N)** button. The button stays disabled until at least one row is checked, and is admin-only. When no unmanaged containers are detected, the tab shows a success state with the message "No unmanaged containers" and the subline "All running containers are managed by Sencho." + +## Troubleshooting + + + + The row carries a **Sencho** pill, which means the resource is the running Sencho instance's own image, attached network, or named volume. Sencho refuses to delete the resources that keep itself running so an admin cannot take the dashboard offline by accident. Bind-mounted host paths are not shown in the Volumes tab and are not affected. + + + The Unmanaged tab filters the running Sencho container out by design so it cannot be selected and purged. Other containers started outside Sencho (with `docker run` or by a Compose project outside `COMPOSE_DIR`) still appear normally. To inspect the Sencho container itself, use **Host Console** or `docker ps` on the host. + + diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 5f930a64..0ea89846 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -61,6 +61,7 @@ interface DockerImage { Containers: number; managedBy: string | null; managedStatus: 'managed' | 'unmanaged' | 'unused'; + isSencho: boolean; } interface DockerVolume { @@ -71,6 +72,7 @@ interface DockerVolume { CreatedAt: string | null; managedBy: string | null; managedStatus: 'managed' | 'unmanaged'; + isSencho: boolean; } const NETWORK_DRIVERS = ['bridge', 'overlay', 'macvlan', 'host', 'none'] as const; @@ -83,6 +85,7 @@ export interface DockerNetwork { Scope: string; managedBy: string | null; managedStatus: 'managed' | 'unmanaged' | 'system'; + isSencho: boolean; } interface UnmanagedContainer { @@ -175,6 +178,20 @@ function ManagedBadge({ status, managedBy }: { return null; } +// ── Sencho Self-Protection Badge ─────────────────────────────────────────────── + +function SenchoBadge() { + return ( + + + Sencho + + ); +} + // ── Severity Badge ───────────────────────────────────────────────────────────── const SEVERITY_BADGE_CLASSES: Record = { @@ -793,6 +810,7 @@ export default function ResourcesView() { {img.Containers > 0 ? "In Use" : "Unused"} + {img.isSencho && } {(() => { const tag = img.RepoTags?.[0]; const summary = tag ? scanSummaries[tag] : undefined; @@ -844,7 +862,14 @@ export default function ResourcesView() { )} - {isAdmin && } @@ -890,7 +915,12 @@ export default function ResourcesView() { {vol.Name} {vol.Driver} {vol.Mountpoint} - + +
+ + {vol.isSencho && } +
+
{isAdmin && ( @@ -905,7 +935,14 @@ export default function ResourcesView() { )} - {isAdmin && }
@@ -1013,7 +1050,12 @@ export default function ResourcesView() { {net.Name} {net.Driver} {net.Scope} - + +
+ + {net.isSencho && } +
+