From 0294b76944223fa15f73ee6ce275353b175af106 Mon Sep 17 00:00:00 2001 From: Doug Horner Date: Sun, 6 Sep 2026 00:54:03 -0400 Subject: [PATCH] feat(ssh): sharing list controls who may SSH into a container Owner + collaborators are now the SSH allowlist, evaluated by the manager on every connection. Closes #471 (part 1 of 2; enrollment script for existing containers follows). Manager: - Containers.sshAccessTokenHash + Container.rotate/ensure/verifySshAccessToken - GET /api/v1/containers/:id/ssh-access/:username (container-token auth): 204 owner/collaborator, 403 otherwise - POST /sites/:siteId/containers/:id/ssh-access/token (owner/admin) mints - CONTAINER_ID / CONTAINER_SSH_TOKEN reserved env keys injected on create, preserved across reconfigure; MANAGER_URL seeded as a default env var - serializer: sshAccessEnforced Base image: - ssh-access-check (AuthorizedKeysCommand wrapper + PAM account hook) asks the manager; cached allow honoured when the manager is unreachable; unenrolled containers behave as before - ssh-access-setup.service copies the token out of PID 1's env into /etc/ssh-access (kept out of /etc/environment) Client: Sharing copy + SshAccessBadge. Docs + OpenAPI updated. --- create-a-container/bin/create-container.js | 6 +- .../bin/reconfigure-container.js | 7 +- .../components/containers/SshAccessBadge.tsx | 22 +++ create-a-container/client/src/lib/types.ts | 2 + .../pages/containers/ContainerFormPage.tsx | 17 ++- .../pages/containers/ContainersListPage.tsx | 12 +- ...05000000-add-container-ssh-access-token.js | 18 +++ ...20260905000001-seed-manager-url-env-var.js | 73 ++++++++++ create-a-container/models/container.js | 89 +++++++++++- create-a-container/openapi.v1.yaml | 31 ++++ .../v1/__tests__/containers.serialize.test.js | 1 + .../api/v1/__tests__/ssh-access.api.test.js | 135 ++++++++++++++++++ .../routers/api/v1/containers.js | 19 +++ create-a-container/routers/api/v1/index.js | 2 + .../routers/api/v1/ssh-access.js | 52 +++++++ images/base/50-sss-ssh-authorizedkeys.conf | 9 +- images/base/Dockerfile | 12 ++ images/base/environment.sh | 6 +- images/base/ssh-access-authorized-keys.sh | 5 + images/base/ssh-access-check.sh | 63 ++++++++ images/base/ssh-access-setup.service | 13 ++ images/base/ssh-access-setup.sh | 30 ++++ images/base/test-ssh-access-check.sh | 74 ++++++++++ .../docs/admins/ldap-servers.md | 4 + .../docs/developers/database-schema.md | 3 +- .../docs/users/creating-containers/web-gui.md | 6 + 26 files changed, 685 insertions(+), 26 deletions(-) create mode 100644 create-a-container/client/src/components/containers/SshAccessBadge.tsx create mode 100644 create-a-container/migrations/20260905000000-add-container-ssh-access-token.js create mode 100644 create-a-container/migrations/20260905000001-seed-manager-url-env-var.js create mode 100644 create-a-container/routers/api/v1/__tests__/ssh-access.api.test.js create mode 100644 create-a-container/routers/api/v1/ssh-access.js create mode 100644 images/base/ssh-access-authorized-keys.sh create mode 100644 images/base/ssh-access-check.sh create mode 100644 images/base/ssh-access-setup.service create mode 100644 images/base/ssh-access-setup.sh create mode 100644 images/base/test-ssh-access-check.sh diff --git a/create-a-container/bin/create-container.js b/create-a-container/bin/create-container.js index 37740deb..b29eff36 100755 --- a/create-a-container/bin/create-container.js +++ b/create-a-container/bin/create-container.js @@ -418,8 +418,10 @@ async function main() { // Apply environment variables and entrypoint. Use the default // (deleteMissing=false): only explicit values are pushed, nothing is unset. // The record now already includes the template's values, and system/NVIDIA - // defaults are merged in by buildLxcEnvConfig. - const envConfig = await container.buildLxcEnvConfig(); + // defaults are merged in by buildLxcEnvConfig. The SSH-access token lets + // sshd in the container ask the manager who may log in. + const sshAccessToken = await container.ensureSshAccessToken(templateConfig.env); + const envConfig = await container.buildLxcEnvConfig({ sshAccessToken }); if (Object.keys(envConfig).length > 0) { console.log('Applying environment variables and entrypoint...'); const updateTask = await client.updateLxcConfig(node.name, vmid, envConfig); diff --git a/create-a-container/bin/reconfigure-container.js b/create-a-container/bin/reconfigure-container.js index 1f702ce9..1b59dd1e 100644 --- a/create-a-container/bin/reconfigure-container.js +++ b/create-a-container/bin/reconfigure-container.js @@ -83,8 +83,11 @@ async function main() { // Build config from environment variables and entrypoint. Pass // deleteMissing so that clearing env vars or removing a custom entrypoint // actually unsets them on the existing container (vs. create, which must - // preserve template-provided values). - const lxcConfig = await container.buildLxcEnvConfig({ deleteMissing: true }); + // preserve template-provided values). The SSH-access token already in the + // container's env is carried over so the running credential stays valid. + const currentConfig = await client.lxcConfig(node.name, container.containerId); + const sshAccessToken = await container.ensureSshAccessToken(currentConfig.env); + const lxcConfig = await container.buildLxcEnvConfig({ deleteMissing: true, sshAccessToken }); if (Object.keys(lxcConfig).length > 0) { console.log('Applying LXC configuration...'); diff --git a/create-a-container/client/src/components/containers/SshAccessBadge.tsx b/create-a-container/client/src/components/containers/SshAccessBadge.tsx new file mode 100644 index 00000000..4c3436b5 --- /dev/null +++ b/create-a-container/client/src/components/containers/SshAccessBadge.tsx @@ -0,0 +1,22 @@ +import { Badge } from '@mieweb/ui'; +import type { Container } from '@/lib/types'; + +/** + * Whether sshd inside the container enforces the sharing list (owner + + * collaborators). Containers created before enforcement existed stay open + * until enrolled. + */ +export function SshAccessBadge({ container }: { container: Pick }) { + return container.sshAccessEnforced ? ( + + SSH enforced + + ) : ( + + SSH not enforced + + ); +} diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts index 55b5df7a..8d928fbe 100644 --- a/create-a-container/client/src/lib/types.ts +++ b/create-a-container/client/src/lib/types.ts @@ -228,6 +228,8 @@ export interface Container { owner: string; /** Usernames this container is shared with (collaborators). */ collaborators: string[]; + /** Whether sshd in the container limits logins to owner + collaborators. */ + sshAccessEnforced: boolean; ipv4Address: string | null; macAddress: string | null; status: ContainerStatus; diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx index ef6e909e..5622fd11 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -34,6 +34,7 @@ import { FormPageHeader } from '@/components/FormPageHeader'; import { randomHostname } from '@/lib/randomHostname'; import { ResourcesSection } from '@/components/containers/ResourcesSection'; import { AddCollaboratorField, CollaboratorChips, CollaboratorsManager } from '@/components/containers/CollaboratorsManager'; +import { SshAccessBadge } from '@/components/containers/SshAccessBadge'; import type { ContainerCreateResult, ContainerMetadata } from '@/lib/types'; function useDebouncedValue(value: T, delay = 500): T { @@ -823,10 +824,13 @@ export function ContainerFormPage() { {isEdit && container ? ( <> -

- Share this container with other users for collaboration. They will see it in - their All containers tab. -

+
+

+ Only the owner and collaborators can SSH into this container. Collaborators + also see it in their All containers tab. +

+ +

- Optionally add other users as collaborators. They will see this container in - their All containers tab once it is created. + Optionally add other users as collaborators. Only you and your collaborators + will be able to SSH into this container; they will also see it in their All + containers tab once it is created.

-

- Share this container with other users for collaboration. Shared users can find it - by filtering the containers list by your username. -

+
+

+ Only the owner and collaborators can SSH into this container. Shared users can + also find it by filtering the containers list by your username. +

+ {shareTarget && } +
{shareTarget && siteId && ( 0) { + try { + const parsed = JSON.parse(rows[0].value); + if (Array.isArray(parsed)) { + existing = parsed; + } else if (typeof parsed === 'object' && parsed !== null) { + existing = Object.entries(parsed).map(([key, value]) => ({ key, value, description: '' })); + } + } catch (_) { + existing = []; + } + } + + const existingKeys = new Set(existing.map((e) => e.key)); + const toAdd = MANAGER_DEFAULTS.filter((e) => !existingKeys.has(e.key)); + if (toAdd.length === 0) return; + + const merged = [...existing, ...toAdd]; + const now = new Date(); + + if (rows.length > 0) { + await queryInterface.sequelize.query( + `UPDATE "Settings" SET value = :value, "updatedAt" = :now WHERE key = 'default_container_env_vars'`, + { replacements: { value: JSON.stringify(merged), now } }, + ); + } else { + await queryInterface.bulkInsert('Settings', [ + { key: 'default_container_env_vars', value: JSON.stringify(merged), createdAt: now, updatedAt: now }, + ]); + } + }, + + async down(queryInterface) { + const [rows] = await queryInterface.sequelize.query( + `SELECT value FROM "Settings" WHERE key = 'default_container_env_vars'`, + ); + if (rows.length === 0) return; + let existing; + try { + existing = JSON.parse(rows[0].value); + } catch (_) { + return; + } + if (!Array.isArray(existing)) return; + const remove = new Set(MANAGER_DEFAULTS.map((e) => e.key)); + const filtered = existing.filter((e) => !remove.has(e.key)); + await queryInterface.sequelize.query( + `UPDATE "Settings" SET value = :value, "updatedAt" = :now WHERE key = 'default_container_env_vars'`, + { replacements: { value: JSON.stringify(filtered), now: new Date() } }, + ); + }, +}; diff --git a/create-a-container/models/container.js b/create-a-container/models/container.js index c5b496fa..807e9dc3 100644 --- a/create-a-container/models/container.js +++ b/create-a-container/models/container.js @@ -2,6 +2,16 @@ const { Model } = require('sequelize'); +const { generateApiKey, hashApiKey, verifyApiKey } = require('../utils/apikey'); + +// Env vars the manager owns. Injected by buildLxcEnvConfig and stripped from +// every other source so neither users nor admin defaults can override them. +const RESERVED_ENV_KEYS = ['CONTAINER_ID', 'CONTAINER_SSH_TOKEN']; + +// Accepted login names (POSIX-ish); anything else is rejected before it can +// reach a shell or config file. +const USERNAME_RE = /^[a-z_][a-z0-9_-]{0,31}$/; + module.exports = (sequelize, DataTypes) => { class Container extends Model { /** @@ -49,6 +59,61 @@ module.exports = (sequelize, DataTypes) => { return this.collaborators.map((c) => c.username).sort((a, b) => a.localeCompare(b)); } + /** + * Whether a user may SSH into this container: the owner or a collaborator. + * This is the single rule sshd inside the container consults (via the + * ssh-access endpoint). Requires `collaborators` to be eager-loaded. + * @param {string} username + * @returns {boolean} + */ + sshAllowsUser(username) { + return this.username === username || this.collaborators.some((c) => c.username === username); + } + + /** + * Whether the container enforces the sharing list for SSH, i.e. it has + * been issued a token to call back with. Null hash = legacy/unenrolled. + * @returns {boolean} + */ + sshAccessEnforced() { + return !!this.sshAccessTokenHash; + } + + /** + * Verify a token presented by the container against the stored hash. + * @param {string} token + * @returns {Promise} + */ + async verifySshAccessToken(token) { + if (!this.sshAccessTokenHash || !token) return false; + return verifyApiKey(this.sshAccessTokenHash, token); + } + + /** + * Mint a new SSH-access token, persist its hash, and return the plaintext. + * The plaintext is only ever handed to the container (env or enrollment). + * @returns {Promise} + */ + async rotateSshAccessToken() { + const token = generateApiKey(); + await this.update({ sshAccessTokenHash: await hashApiKey(token) }); + return token; + } + + /** + * Return the plaintext token to inject into the container's env. Reuses + * the token already present in the container's current LXC env when it + * still matches the stored hash (so a reconfigure doesn't invalidate the + * running container's credential); otherwise mints a new one. + * @param {string} [currentLxcEnv] - Proxmox NUL-separated `env` string + * @returns {Promise} + */ + async ensureSshAccessToken(currentLxcEnv) { + const existing = this.constructor.parseLxcEnvString(currentLxcEnv, { keepReserved: true }).CONTAINER_SSH_TOKEN; + if (existing && (await this.verifySshAccessToken(existing))) return existing; + return this.rotateSshAccessToken(); + } + /** * Normalize a set of environment variables into a safe, flat * { KEY: stringValue } object suitable for building the Proxmox `env` @@ -68,7 +133,7 @@ module.exports = (sequelize, DataTypes) => { * @param {*} input - Candidate env vars, ideally a { key: value } object * @returns {object} Flat object of validated { KEY: stringValue } */ - static normalizeEnvVars(input) { + static normalizeEnvVars(input, { keepReserved = false } = {}) { const out = {}; if (!input || typeof input !== 'object' || Array.isArray(input)) return out; @@ -79,6 +144,7 @@ module.exports = (sequelize, DataTypes) => { for (const [rawKey, rawValue] of Object.entries(input)) { const key = typeof rawKey === 'string' ? rawKey.trim() : ''; if (!validKey.test(key)) continue; + if (!keepReserved && RESERVED_ENV_KEYS.includes(key)) continue; // Only primitives (string/number/boolean) become values; skip // null/undefined and objects/arrays. @@ -100,14 +166,14 @@ module.exports = (sequelize, DataTypes) => { * @param {string|null|undefined} envStr - Raw Proxmox `env` value * @returns {object} Flat object of validated { KEY: value } */ - static parseLxcEnvString(envStr) { + static parseLxcEnvString(envStr, options) { if (!envStr || typeof envStr !== 'string') return {}; const raw = {}; for (const pair of envStr.split('\0')) { const eq = pair.indexOf('='); if (eq > 0) raw[pair.substring(0, eq)] = pair.substring(eq + 1); } - return this.normalizeEnvVars(raw); + return this.normalizeEnvVars(raw, options); } /** @@ -239,21 +305,25 @@ module.exports = (sequelize, DataTypes) => { * that resolve to empty are added to Proxmox's `delete` list (removing any * existing value). When false, they are simply omitted, preserving whatever * the container/template already has. + * @param {string} [options.sshAccessToken] - Plaintext token from + * ensureSshAccessToken. When given, CONTAINER_ID/CONTAINER_SSH_TOKEN are + * injected with top precedence so the container can call back. * @returns {Promise} Config object with 'env' and 'entrypoint' * properties (and, when deleteMissing is set, a 'delete' list) */ - async buildLxcEnvConfig({ deleteMissing = false } = {}) { + async buildLxcEnvConfig({ deleteMissing = false, sshAccessToken } = {}) { const config = {}; const deleteList = []; // Merge precedence (lowest to highest): - // system defaults < NVIDIA defaults < user-defined values + // system defaults < NVIDIA defaults < user-defined values < reserved // Every source is already normalized to a safe { KEY: stringValue } map // (see normalizeEnvVars), so the encoding below cannot be corrupted. const mergedEnvVars = { ...(await this.constructor.getSystemDefaultEnvVars()), ...this.nvidiaDefaultEnvVars(), - ...this.parseEnvironmentVars() + ...this.parseEnvironmentVars(), + ...(sshAccessToken ? { CONTAINER_ID: String(this.id), CONTAINER_SSH_TOKEN: sshAccessToken } : {}) }; // Format as NUL-separated list: KEY1=value1\0KEY2=value2\0KEY3=value3 @@ -356,6 +426,11 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.STRING(2000), allowNull: true, defaultValue: null + }, + sshAccessTokenHash: { + type: DataTypes.STRING(255), + allowNull: true, + defaultValue: null } }, { sequelize, @@ -383,5 +458,7 @@ module.exports = (sequelize, DataTypes) => { } ] }); + Container.RESERVED_ENV_KEYS = RESERVED_ENV_KEYS; + Container.USERNAME_RE = USERNAME_RE; return Container; }; \ No newline at end of file diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index 1c1f24f6..81762906 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -149,6 +149,7 @@ components: type: array items: { type: string } description: Usernames the container is shared with + sshAccessEnforced: { type: boolean, description: 'Whether sshd in the container limits logins to the owner and collaborators (the container holds a callback token). False for containers created before enforcement until enrolled.' } containerId: { type: string, nullable: true, description: Provider container id — Proxmox VMID or Docker container id (null until provisioned) } status: { $ref: '#/components/schemas/ContainerStatus' } template: { type: string } @@ -962,6 +963,36 @@ paths: schema: { type: object, properties: { data: { $ref: '#/components/schemas/CollaboratorList' } } } '403': { description: 'forbidden — only the owner/admin may unshare (collaborators can view but not manage)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } '404': { description: 'Container or collaborator not found', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /sites/{siteId}/containers/{id}/ssh-access/token: + post: + operationId: rotate_container_ssh_access_token + tags: [Containers] + summary: Mint (or rotate) the token sshd inside the container uses to ask who may log in (owner/admin). The plaintext is returned once; any previous token stops working. + parameters: + - { in: path, name: siteId, required: true, schema: { type: integer } } + - { in: path, name: id, required: true, schema: { type: integer } } + responses: + '201': + description: New token + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { containerId: { type: integer }, token: { type: string } } } } } + '403': { description: 'forbidden — only the owner/admin', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '404': { $ref: '#/components/responses/NotFound' } + /containers/{id}/ssh-access/{username}: + get: + operationId: check_container_ssh_access + tags: [Containers] + summary: Called by sshd inside a container — may this user log in? Authenticated with the container's own token (Bearer), not a user credential. + security: [{ BearerAuth: [] }] + parameters: + - { in: path, name: id, required: true, schema: { type: integer } } + - { in: path, name: username, required: true, schema: { type: string, pattern: '^[a-z_][a-z0-9_-]{0,31}$' } } + responses: + '204': { description: Allowed — the user is the owner or a collaborator } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { description: Missing or invalid container token, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '403': { description: Denied — not the owner or a collaborator, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/nodes: parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }] diff --git a/create-a-container/routers/api/v1/__tests__/containers.serialize.test.js b/create-a-container/routers/api/v1/__tests__/containers.serialize.test.js index ccf44f5e..cc4c8e36 100644 --- a/create-a-container/routers/api/v1/__tests__/containers.serialize.test.js +++ b/create-a-container/routers/api/v1/__tests__/containers.serialize.test.js @@ -31,6 +31,7 @@ function stubContainer(services) { hostname: 'testct', username: 'alice', collaboratorNames: () => [], + sshAccessEnforced: () => false, ipv4Address: '10.254.1.5', macAddress: null, template: null, diff --git a/create-a-container/routers/api/v1/__tests__/ssh-access.api.test.js b/create-a-container/routers/api/v1/__tests__/ssh-access.api.test.js new file mode 100644 index 00000000..cb71bcfc --- /dev/null +++ b/create-a-container/routers/api/v1/__tests__/ssh-access.api.test.js @@ -0,0 +1,135 @@ +/** + * Sharing drives SSH access. sshd inside a container calls + * GET /api/v1/containers/:id/ssh-access/:username with the container's own + * token; the answer is computed live from owner + collaborators. Owners/admins + * mint that token via POST /sites/:siteId/containers/:id/ssh-access/token. + */ + +const request = require('supertest'); +const { buildApp, bearer } = require('../../../../tests/helpers/app'); +const { resetDb, closeDb, createUser, createApiKey } = require('../../../../tests/helpers/db'); +const { Site, Node, Container, ContainerCollaborator } = require('../../../../models'); + +afterAll(async () => { + await closeDb(); +}); + +describe('ssh-access', () => { + let app; + let owner, collaborator, stranger; + let site, container, token; + + beforeEach(async () => { + await resetDb(); + app = buildApp(); + // First user after resetDb is auto-promoted to admin; burn it. + await createUser({ uid: 'admin0' }); + owner = await createUser({ uid: 'alice' }); + collaborator = await createUser({ uid: 'bob' }); + stranger = await createUser({ uid: 'carol' }); + site = await Site.create({ name: 'test', internalDomain: 'test.example' }); + const node = await Node.create({ name: 'pve1', siteId: site.id }); + container = await Container.create({ + hostname: 'ct1', + username: owner.uid, + nodeId: node.id, + siteId: site.id, + containerId: '101', + }); + await ContainerCollaborator.create({ containerId: container.id, username: collaborator.uid }); + token = await container.rotateSshAccessToken(); + }); + + const check = (user, tok = token) => + request(app).get(`/api/v1/containers/${container.id}/ssh-access/${user}`).set(...bearer(tok)); + + test('owner and collaborator are allowed (204)', async () => { + expect((await check('alice')).status).toBe(204); + expect((await check('bob')).status).toBe(204); + }); + + test('anyone else is denied (403), even with a valid directory account', async () => { + const res = await check('carol'); + expect(res.status).toBe(403); + expect(res.headers['cache-control']).toBe('no-store'); + }); + + test('unsharing takes effect on the next check', async () => { + await ContainerCollaborator.destroy({ where: { containerId: container.id, username: 'bob' } }); + expect((await check('bob')).status).toBe(403); + }); + + test('invalid usernames are rejected before lookup (400)', async () => { + expect((await check('Bad;Name')).status).toBe(400); + expect((await check('Alice')).status).toBe(400); + expect((await check('a'.repeat(33))).status).toBe(400); + }); + + test('missing, wrong, or rotated tokens are unauthorized (401)', async () => { + expect((await request(app).get(`/api/v1/containers/${container.id}/ssh-access/alice`)).status).toBe(401); + expect((await check('alice', 'nope')).status).toBe(401); + // A user API key is not a container token. + const { plainKey } = await createApiKey(owner); + expect((await check('alice', plainKey)).status).toBe(401); + await container.rotateSshAccessToken(); + expect((await check('alice')).status).toBe(401); + }); + + test('unenrolled container (no token hash) never authenticates', async () => { + await container.update({ sshAccessTokenHash: null }); + expect((await check('alice')).status).toBe(401); + }); + + describe('POST /sites/:siteId/containers/:id/ssh-access/token', () => { + const mint = async (user) => { + const { plainKey } = await createApiKey(user); + return request(app) + .post(`/api/v1/sites/${site.id}/containers/${container.id}/ssh-access/token`) + .set(...bearer(plainKey)); + }; + + test('owner mints a token that works and invalidates the previous one', async () => { + const res = await mint(owner); + expect(res.status).toBe(201); + expect(typeof res.body.data.token).toBe('string'); + expect((await check('alice', res.body.data.token)).status).toBe(204); + expect((await check('alice', token)).status).toBe(401); + }); + + test('collaborator may not mint (403); stranger cannot see the container (404)', async () => { + expect((await mint(collaborator)).status).toBe(403); + expect((await mint(stranger)).status).toBe(404); + }); + }); +}); + +describe('Container SSH-access helpers', () => { + test('reserved env keys cannot be set by users or defaults', () => { + const out = Container.normalizeEnvVars({ FOO: 'bar', CONTAINER_SSH_TOKEN: 'x', CONTAINER_ID: '9' }); + expect(out).toEqual({ FOO: 'bar' }); + }); + + test('buildLxcEnvConfig injects reserved keys only when a token is supplied', async () => { + await resetDb(); + const c = Container.build({ id: 42, hostname: 'h', username: 'u', nodeId: 1, siteId: 1, environmentVars: JSON.stringify({ CONTAINER_ID: 'spoof', A: '1' }) }); + const without = await c.buildLxcEnvConfig(); + expect(Container.parseLxcEnvString(without.env, { keepReserved: true })).toEqual({ A: '1' }); + const with_ = await c.buildLxcEnvConfig({ sshAccessToken: 'tok' }); + expect(Container.parseLxcEnvString(with_.env, { keepReserved: true })).toEqual({ A: '1', CONTAINER_ID: '42', CONTAINER_SSH_TOKEN: 'tok' }); + }); + + test('ensureSshAccessToken reuses a still-valid token from the current env, else rotates', async () => { + await resetDb(); + await createUser({ uid: 'admin0' }); + const site = await Site.create({ name: 't2', internalDomain: 't2.example' }); + const node = await Node.create({ name: 'pve2', siteId: site.id }); + const c = await Container.create({ hostname: 'ct2', username: 'admin0', nodeId: node.id, siteId: site.id }); + const first = await c.ensureSshAccessToken(undefined); + expect(await c.verifySshAccessToken(first)).toBe(true); + const reused = await c.ensureSshAccessToken(`A=1\0CONTAINER_SSH_TOKEN=${first}`); + expect(reused).toBe(first); + const rotated = await c.ensureSshAccessToken('CONTAINER_SSH_TOKEN=stale'); + expect(rotated).not.toBe(first); + expect(await c.verifySshAccessToken(first)).toBe(false); + }); +}); diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index a329d57f..619366ce 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -132,6 +132,10 @@ function serializeContainer(c, site, status) { // Additional users this container is shared with. Present on every // payload so consumers can render/manage sharing. collaborators: c.collaboratorNames(), + // Whether sshd inside the container enforces owner+collaborators (the + // container has been issued a callback token). False for containers + // created before this feature until they are enrolled. + sshAccessEnforced: c.sshAccessEnforced(), ipv4Address: c.ipv4Address, macAddress: c.macAddress, // Live status computed from Proxmox + jobs + config (see utils/container-status). @@ -929,6 +933,21 @@ router.delete( }), ); +// POST /containers/:id/ssh-access/token — mint (or rotate) the token sshd in +// the container uses to ask who may log in (owner/admin). The plaintext is +// returned exactly once; used by the enrollment script for existing +// containers. New containers receive theirs via env at creation. +router.post( + '/:id/ssh-access/token', + asyncHandler(async (req, res) => { + const { container } = await loadContainerForSession(req.params.siteId, req.params.id, req.session, { + requireManage: true, + }); + const token = await container.rotateSshAccessToken(); + return created(res, { containerId: container.id, token }); + }), +); + module.exports = router; // Exported for unit tests (containers.serialize.test.js). module.exports.serializeContainer = serializeContainer; diff --git a/create-a-container/routers/api/v1/index.js b/create-a-container/routers/api/v1/index.js index 3e45093a..3e60858f 100644 --- a/create-a-container/routers/api/v1/index.js +++ b/create-a-container/routers/api/v1/index.js @@ -96,6 +96,8 @@ router.use('/jobs', require('./jobs')); router.use('/resource-requests', require('./resource-requests')); router.use('/notifications', require('../../../resources/notifications/router')); router.use('/services', require('../../../resources/services/router')); +// sshd inside a container asks whether a user may log in (container-token auth). +router.use('/containers/:id/ssh-access', require('./ssh-access')); // Final error handler — must come after all routes router.use(jsonErrorHandler); diff --git a/create-a-container/routers/api/v1/ssh-access.js b/create-a-container/routers/api/v1/ssh-access.js new file mode 100644 index 00000000..607c6ed8 --- /dev/null +++ b/create-a-container/routers/api/v1/ssh-access.js @@ -0,0 +1,52 @@ +/** + * /api/v1/containers/:id/ssh-access — consulted by sshd inside a container. + * + * The container authenticates with its own token (CONTAINER_SSH_TOKEN, hashed + * on the Container record), not a user session, so this router is mounted + * outside the /sites tree and applies its own auth. + * + * GET /:username → 204 owner or collaborator, 403 otherwise + * + * Answers are computed live from the DB, so share/unshare takes effect on the + * next SSH connection. + */ + +const express = require('express'); +const { Container } = require('../../../models'); +const { asyncHandler, ApiError } = require('../../../middlewares/api'); + +const router = express.Router({ mergeParams: true }); + +// Bearer = container token. Loads the container onto req.container. +async function containerTokenAuth(req, _res, next) { + const id = parseInt(req.params.id, 10); + const auth = req.get('Authorization') || ''; + const token = auth.startsWith('Bearer ') ? auth.substring(7) : ''; + if (!Number.isInteger(id) || id <= 0 || !token) { + throw new ApiError(401, 'unauthorized', 'Container token required'); + } + const container = await Container.findByPk(id, { include: [{ association: 'collaborators' }] }); + if (!container || !(await container.verifySshAccessToken(token))) { + throw new ApiError(401, 'unauthorized', 'Invalid container token'); + } + req.container = container; + next(); +} + +router.use(asyncHandler(containerTokenAuth)); + +router.get( + '/:username', + asyncHandler(async (req, res) => { + const { username } = req.params; + if (!Container.USERNAME_RE.test(username)) { + throw new ApiError(400, 'invalid_request', 'Invalid username'); + } + res.set('Cache-Control', 'no-store'); + if (req.container.sshAllowsUser(username)) return res.status(204).end(); + console.warn(`[ssh-access] denied ${username} on container ${req.container.id} (${req.container.hostname})`); + throw new ApiError(403, 'forbidden', 'User is not the owner or a collaborator of this container'); + }), +); + +module.exports = router; diff --git a/images/base/50-sss-ssh-authorizedkeys.conf b/images/base/50-sss-ssh-authorizedkeys.conf index e4c83cfe..ef736416 100644 --- a/images/base/50-sss-ssh-authorizedkeys.conf +++ b/images/base/50-sss-ssh-authorizedkeys.conf @@ -1,4 +1,7 @@ # Fetch users' authorized SSH public keys from SSSD (which serves the LDAP -# `sshPublicKey` attribute). %u is replaced by sshd with the login username. -AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u -AuthorizedKeysCommandUser nobody +# `sshPublicKey` attribute) — but only after the manager confirms the user is +# this container's owner or a collaborator (see ssh-access-check.sh). %u is +# replaced by sshd with the login username. Runs as the sshaccess user so it +# can read /etc/ssh-access/token. +AuthorizedKeysCommand /usr/local/bin/ssh-access-authorized-keys %u +AuthorizedKeysCommandUser sshaccess diff --git a/images/base/Dockerfile b/images/base/Dockerfile index d0d7a97c..e5c80946 100644 --- a/images/base/Dockerfile +++ b/images/base/Dockerfile @@ -32,6 +32,18 @@ COPY --chmod=0440 ldapusers /etc/sudoers.d/ldapusers COPY --chmod=0644 ldap.conf /etc/ldap/ldap.conf COPY --chmod=0755 git-identity.sh /etc/profile.d/git-identity.sh +# Per-container SSH access: sshd asks the manager whether the login user is the +# container's owner or a collaborator, on both the key path +# (AuthorizedKeysCommand wrapper) and the password/MFA path (PAM account hook). +# The sshaccess user exists so the token file need not be readable by nobody. +COPY --chmod=0755 ssh-access-check.sh /usr/local/bin/ssh-access-check +COPY --chmod=0755 ssh-access-authorized-keys.sh /usr/local/bin/ssh-access-authorized-keys +COPY --chmod=0755 ssh-access-setup.sh /usr/local/bin/ssh-access-setup.sh +COPY ssh-access-setup.service /etc/systemd/system/ssh-access-setup.service +RUN useradd --system --no-create-home --shell /usr/sbin/nologin sshaccess && \ + sed -i '/^@include common-account/i account required pam_exec.so quiet /usr/local/bin/ssh-access-check' /etc/pam.d/sshd && \ + systemctl enable ssh-access-setup.service + # The following service ensures environment variables set in the OCI metadata # or container configuration are passed to programs executed by systemd which # includes user sessions diff --git a/images/base/environment.sh b/images/base/environment.sh index ed6d20db..a290b0a9 100755 --- a/images/base/environment.sh +++ b/images/base/environment.sh @@ -3,5 +3,7 @@ # them into /etc/environment. This allows the container runtime (LXC or Docker) # to set environment variables for system containers, that are then processed # by pam_env.so such that all processes in the container inhert those variables -# We filter out certain variables that may cause issues with user sessions. -/etc/environment +# We filter out certain variables that may cause issues with user sessions, +# and CONTAINER_SSH_TOKEN, which must not be world-readable (ssh-access-setup.sh +# copies it to /etc/ssh-access/token instead). + may SSH into this container (owner or +# collaborator). Exit 0 = allow, 1 = deny. Called by sshd's +# AuthorizedKeysCommand wrapper (user as $1) and by pam_exec in the account +# phase (user in $PAM_USER), so both key and password/MFA logins are gated. +# +# Enrollment files (written by ssh-access-setup.sh from the container env): +# /etc/ssh-access/url manager base URL +# /etc/ssh-access/id this container's manager id +# /etc/ssh-access/token bearer token (readable by root and sshaccess only) +# Without them the container is unenrolled and behaves as before (allow). +# +# Manager unreachable: honour a recent cached allow (touched on every 204) so +# the owner keeps access through an outage while strangers stay out. +set -u + +CONF_DIR=${SSH_ACCESS_DIR:-/etc/ssh-access} +CACHE_DIR=${SSH_ACCESS_CACHE_DIR:-/var/cache/ssh-access} +CACHE_TTL=${SSH_ACCESS_CACHE_TTL:-86400} + +user=${1:-${PAM_USER:-}} + +log() { logger -t ssh-access -p auth.notice -- "$*" 2>/dev/null || echo "ssh-access: $*" >&2; } + +if ! [[ "$user" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then + log "deny '${user}': invalid username" + exit 1 +fi + +if [ ! -s "$CONF_DIR/token" ] || [ ! -s "$CONF_DIR/url" ] || [ ! -s "$CONF_DIR/id" ]; then + log "allow ${user}: container not enrolled (no ${CONF_DIR}/token)" + exit 0 +fi + +url="$(<"$CONF_DIR/url")/api/v1/containers/$(<"$CONF_DIR/id")/ssh-access/${user}" +# Token goes in via curl's config on stdin so it never appears in argv. +code=$(printf 'header = "Authorization: Bearer %s"\n' "$(<"$CONF_DIR/token")" \ + | curl -sS -K - -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null) || code=000 + +cache="$CACHE_DIR/$user" +case "$code" in + 204) + # rm+touch so a root-owned entry (pam_exec) can be refreshed by sshaccess. + mkdir -p "$CACHE_DIR" 2>/dev/null; rm -f "$cache"; touch "$cache" 2>/dev/null + exit 0 + ;; + 403|400) + rm -f "$cache" + log "deny ${user}: not owner or collaborator (${code})" + exit 1 + ;; + *) + # 000 (unreachable), 5xx, or 401 (our token no longer valid): can't verify. + if [ -f "$cache" ] && [ $(( $(date +%s) - $(date -r "$cache" +%s) )) -lt "$CACHE_TTL" ]; then + log "allow ${user}: manager unavailable (${code}), cached allow" + exit 0 + fi + log "deny ${user}: manager unavailable (${code}), no cached allow" + exit 1 + ;; +esac diff --git a/images/base/ssh-access-setup.service b/images/base/ssh-access-setup.service new file mode 100644 index 00000000..b6040c0d --- /dev/null +++ b/images/base/ssh-access-setup.service @@ -0,0 +1,13 @@ +[Unit] +Description=Enroll sshd with the manager's per-container SSH access policy +# Needs PID 1's environment (populated before environment.service copies it) +# and must finish before sshd accepts logins. +After=environment.service +Before=ssh.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/ssh-access-setup.sh + +[Install] +WantedBy=multi-user.target diff --git a/images/base/ssh-access-setup.sh b/images/base/ssh-access-setup.sh new file mode 100644 index 00000000..cd76e494 --- /dev/null +++ b/images/base/ssh-access-setup.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Boot-time enrollment: copy the SSH-access callback settings from PID 1's +# environment (set by the manager on the LXC config) into /etc/ssh-access so +# ssh-access-check can read them without the token ever landing in the +# world-readable /etc/environment. Idempotent; a no-op when the vars are absent +# (container created before this feature — SSH stays open until enrolled). +set -euo pipefail + +CONF_DIR=/etc/ssh-access +CACHE_DIR=/var/cache/ssh-access + +getenv() { tr '\0' '\n' "$CONF_DIR/url.tmp" +printf '%s\n' "$id" >"$CONF_DIR/id.tmp" +(umask 027; printf '%s\n' "$token" >"$CONF_DIR/token.tmp") +chgrp sshaccess "$CONF_DIR"/*.tmp +for f in url id token; do mv -f "$CONF_DIR/$f.tmp" "$CONF_DIR/$f"; done +echo "ssh-access: enrolled as container $id with $url" diff --git a/images/base/test-ssh-access-check.sh b/images/base/test-ssh-access-check.sh new file mode 100644 index 00000000..3af2d1db --- /dev/null +++ b/images/base/test-ssh-access-check.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Smoke test for images/base/ssh-access-check.sh with a stubbed curl. +# Run: bash images/base/test-ssh-access-check.sh +set -u + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/ssh-access-check.sh" +STUBS=$(mktemp -d) +export SSH_ACCESS_DIR=$(mktemp -d) +export SSH_ACCESS_CACHE_DIR=$(mktemp -d) +export SSH_ACCESS_CACHE_TTL=60 + +# curl stub: returns the status code in $CURL_CODE and records the URL + stdin config. +cat >"$STUBS/curl" <<'EOF' +#!/usr/bin/env bash +cat >/tmp/ssh-access-curl-config.log +for a in "$@"; do case "$a" in http*) echo "$a" >/tmp/ssh-access-curl-url.log;; esac; done +[ "${CURL_CODE:-000}" = 000 ] && exit 7 +printf '%s' "$CURL_CODE" +EOF +cat >"$STUBS/logger" <<'EOF' +#!/usr/bin/env bash +echo "$*" >>/tmp/ssh-access-logger.log +EOF +chmod +x "$STUBS/curl" "$STUBS/logger" +export PATH="$STUBS:$PATH" + +fail=0 +check() { # desc expected actual + if [ "$2" = "$3" ]; then echo "PASS: $1"; else echo "FAIL: $1 (expected '$2', got '$3')"; fail=1; fi +} +run() { # code user -> exit status + CURL_CODE=$1 bash "$SCRIPT" "$2" >/dev/null 2>&1; echo $? +} +rm -f /tmp/ssh-access-*.log + +# --- Unenrolled: no token files -> allow, curl never called --- +check "unenrolled allows" 0 "$(run 204 alice)" +[ -f /tmp/ssh-access-curl-url.log ] && { echo "FAIL: curl called while unenrolled"; fail=1; } || echo "PASS: no manager call while unenrolled" + +# --- Enrolled --- +printf 'https://manager.example\n' >"$SSH_ACCESS_DIR/url" +printf '42\n' >"$SSH_ACCESS_DIR/id" +printf 'sekrit\n' >"$SSH_ACCESS_DIR/token" + +check "204 allows" 0 "$(run 204 alice)" +check "url built from id + user" "https://manager.example/api/v1/containers/42/ssh-access/alice" "$(cat /tmp/ssh-access-curl-url.log)" +grep -q 'Authorization: Bearer sekrit' /tmp/ssh-access-curl-config.log && echo "PASS: token sent via stdin config" || { echo "FAIL: token header"; fail=1; } +[ -f "$SSH_ACCESS_CACHE_DIR/alice" ] && echo "PASS: allow cached" || { echo "FAIL: cache not written"; fail=1; } + +check "403 denies" 1 "$(run 403 bob)" +check "400 denies" 1 "$(run 400 bob)" +check "403 clears cache" 1 "$(run 403 alice)" +[ -f "$SSH_ACCESS_CACHE_DIR/alice" ] && { echo "FAIL: cache kept after deny"; fail=1; } || echo "PASS: cache cleared on deny" + +# --- Manager unreachable: cached allow within TTL, otherwise deny --- +run 204 alice >/dev/null +check "unreachable + fresh cache allows" 0 "$(run 000 alice)" +check "unreachable + no cache denies" 1 "$(run 000 carol)" +check "401 (token rotated) falls back to cache" 0 "$(run 401 alice)" +touch -t 202001010000 "$SSH_ACCESS_CACHE_DIR/alice" +check "unreachable + stale cache denies" 1 "$(run 000 alice)" + +# --- Username validation happens before any call --- +rm -f /tmp/ssh-access-curl-url.log +check "invalid username denied" 1 "$(run 204 'Bad;Name')" +check "path traversal denied" 1 "$(run 204 '../x')" +[ -f /tmp/ssh-access-curl-url.log ] && { echo "FAIL: curl called for invalid user"; fail=1; } || echo "PASS: no call for invalid user" + +# --- PAM path: user comes from PAM_USER --- +check "PAM_USER honoured" 0 "$(CURL_CODE=204 PAM_USER=alice bash "$SCRIPT" >/dev/null 2>&1; echo $?)" +check "PAM_USER empty denied" 1 "$(CURL_CODE=204 PAM_USER= bash "$SCRIPT" >/dev/null 2>&1; echo $?)" + +rm -rf "$STUBS" "$SSH_ACCESS_DIR" "$SSH_ACCESS_CACHE_DIR" /tmp/ssh-access-*.log +exit $fail diff --git a/mie-opensource-landing/docs/admins/ldap-servers.md b/mie-opensource-landing/docs/admins/ldap-servers.md index 02696302..67b4ce83 100644 --- a/mie-opensource-landing/docs/admins/ldap-servers.md +++ b/mie-opensource-landing/docs/admins/ldap-servers.md @@ -73,6 +73,10 @@ In the admin UI: **Settings** → **Default Container Environment Variables**. T | `SSSD_LDAP_DEFAULT_BIND_DN` | *(blank)* | No | DN used to bind for lookups. Leave blank for anonymous bind; set it if your directory disallows anonymous searches (e.g. `cn=svc-sssd,ou=services,dc=example,dc=com`). | | `SSSD_DEFAULT_AUTHTOK_TYPE` | *(blank)* | No | Type of the bind credential, typically `password`. Required when `SSSD_LDAP_DEFAULT_BIND_DN` is set. | | `SSSD_DEFAULT_AUTHTOK` | *(blank)* | No | The bind credential (password) for the bind DN. Required when `SSSD_LDAP_DEFAULT_BIND_DN` is set. | +| `MANAGER_URL` | *(blank)* | Yes | Public base URL of this manager as reachable **from containers** (e.g. `https://manager.example.com`). sshd inside each container calls `GET /api/v1/containers//ssh-access/` to allow only the container's owner and collaborators. Blank ⇒ new containers are created unenrolled and SSH stays open to every user the `SSSD_LDAP_ACCESS_FILTER` admits. | + +!!! note "Per-container SSH access vs. the directory filter" + `SSSD_LDAP_ACCESS_FILTER` is a cluster-wide, directory-level gate (which accounts may log in anywhere). Owner/collaborator enforcement is per container and is evaluated by the manager on every SSH connection; both must allow a user. The manager injects `CONTAINER_ID` and `CONTAINER_SSH_TOKEN` into each new container's environment (they are reserved and cannot be set by users). Containers created before this feature show **SSH not enforced** until enrolled. !!! tip `SSSD_LDAP_DEFAULT_BIND_DN`, `SSSD_DEFAULT_AUTHTOK_TYPE`, and `SSSD_DEFAULT_AUTHTOK` work as a set. Provide all three when your directory requires an authenticated bind to read users and groups; leave all three blank to bind anonymously. The service account only needs read access to the user and group subtrees — user passwords are verified by a separate bind as the authenticating user. diff --git a/mie-opensource-landing/docs/developers/database-schema.md b/mie-opensource-landing/docs/developers/database-schema.md index 149fec58..d1d708de 100644 --- a/mie-opensource-landing/docs/developers/database-schema.md +++ b/mie-opensource-landing/docs/developers/database-schema.md @@ -74,6 +74,7 @@ erDiagram string ipv4Address UK string aiContainer boolean nvidiaRequested "default: false" + string sshAccessTokenHash "nullable; argon2" } Services { @@ -193,7 +194,7 @@ Proxmox VE server within a site. `name` must match Proxmox hostname (unique). `i Site agent registered by its check-in (`POST /api/v1/agents`, every 30s). Unique composite index on `(siteId, hostname)`. `services` stores the per-service status reported by the agent (`{ nginx: { state, lastApply }, ... }`); `lastCheckinAt` drives the online/offline health shown on the web client's Agents page. Belongs to Site. See [agent](agent.md). ### Container -LXC container on a Proxmox node. Unique composite index on `(nodeId, containerId)`. `hostname`, `macAddress`, `ipv4Address` globally unique. `nvidiaRequested` indicates GPU passthrough was requested — the container is assigned to an NVIDIA-capable node and the nvidia hookscript is attached. Belongs to Node and optionally to a Job. +LXC container on a Proxmox node. Unique composite index on `(nodeId, containerId)`. `hostname`, `macAddress`, `ipv4Address` globally unique. `nvidiaRequested` indicates GPU passthrough was requested — the container is assigned to an NVIDIA-capable node and the nvidia hookscript is attached. `sshAccessTokenHash` is the argon2 hash of the token sshd inside the container presents to `GET /api/v1/containers/:id/ssh-access/:username`, which answers from `username` (owner) plus `ContainerCollaborators`; `NULL` means the container is not enrolled and SSH is not restricted. The plaintext is only ever in the container's env (`CONTAINER_SSH_TOKEN`). Belongs to Node and optionally to a Job. ### Service (STI) Base model with `type` discriminator (`http`, `transport`, `dns`). Belongs to Container. diff --git a/mie-opensource-landing/docs/users/creating-containers/web-gui.md b/mie-opensource-landing/docs/users/creating-containers/web-gui.md index eb4a5889..778f578e 100644 --- a/mie-opensource-landing/docs/users/creating-containers/web-gui.md +++ b/mie-opensource-landing/docs/users/creating-containers/web-gui.md @@ -89,6 +89,12 @@ Click **Create Container**. You'll be redirected to a page to watch the creation **Proxmox Console:** [{{ proxmox_url }}]({{ proxmox_url }}) +### Who can SSH in + +Only the container's **owner** and its **collaborators** (the **Sharing** section on the container's edit page) can log in over SSH. Keys come from the `sshPublicKey` on your directory account; sharing controls *where* they are accepted. Adding or removing a collaborator applies on their next SSH connection — no restart. Collaborators have the same shell access as the owner (including `sudo`), so share only with people you would give root. + +The Sharing section shows **SSH enforced** when the container checks logins against this list, or **SSH not enforced** for containers created before this feature that have not yet been enrolled by an administrator. + ## Managing Containers - **Start/Stop/Restart/Force Stop** via the Actions column