Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions create-a-container/bin/create-container.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions create-a-container/bin/reconfigure-container.js
Original file line number Diff line number Diff line change
Expand Up @@ -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...');
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Container, 'sshAccessEnforced'> }) {
return container.sshAccessEnforced ? (
<Badge variant="success" aria-label="SSH access is limited to the owner and collaborators">
SSH enforced
</Badge>
) : (
<Badge
variant="warning"
aria-label="SSH access is not limited to the owner and collaborators; enrollment required"
>
SSH not enforced
</Badge>
);
}
2 changes: 2 additions & 0 deletions create-a-container/client/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(value: T, delay = 500): T {
Expand Down Expand Up @@ -823,10 +824,13 @@ export function ContainerFormPage() {
<CardContent className={sectionContentClass}>
{isEdit && container ? (
<>
<p className="text-sm text-muted-foreground">
Share this container with other users for collaboration. They will see it in
their All containers tab.
</p>
<div className="flex items-start justify-between gap-3">
<p className="text-sm text-muted-foreground">
Only the owner and collaborators can SSH into this container. Collaborators
also see it in their All containers tab.
</p>
<SshAccessBadge container={container} />
</div>
<CollaboratorsManager
siteId={siteId!}
containerId={container.id}
Expand All @@ -836,8 +840,9 @@ export function ContainerFormPage() {
) : (
<>
<p className="text-sm text-muted-foreground">
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.
</p>
<CollaboratorChips
usernames={collaborators}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useSession } from '@/lib/auth';
import { keys, queries } from '@/lib/queries';
import { ButtonLink } from '@/components/ButtonLink';
import { CollaboratorsManager } from '@/components/containers/CollaboratorsManager';
import { SshAccessBadge } from '@/components/containers/SshAccessBadge';
import { ContainersDataGrid } from '@/components/containers/ContainersDataGrid';
import type { Container } from '@/lib/types';

Expand Down Expand Up @@ -144,10 +145,13 @@ export function ContainersListPage() {
<ModalClose />
</ModalHeader>
<ModalBody className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Share this container with other users for collaboration. Shared users can find it
by filtering the containers list by your username.
</p>
<div className="flex items-start justify-between gap-3">
<p className="text-sm text-muted-foreground">
Only the owner and collaborators can SSH into this container. Shared users can
also find it by filtering the containers list by your username.
</p>
{shareTarget && <SshAccessBadge container={shareTarget} />}
</div>
{shareTarget && siteId && (
<CollaboratorsManager
siteId={siteId}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
// argon2 hash of the token a container presents when asking the manager
// whether a user may SSH in. NULL = container not enrolled (SSH open).
await queryInterface.addColumn('Containers', 'sshAccessTokenHash', {
type: Sequelize.STRING(255),
allowNull: true,
defaultValue: null,
});
},

async down(queryInterface) {
await queryInterface.removeColumn('Containers', 'sshAccessTokenHash');
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use strict';

// Seeds MANAGER_URL into default_container_env_vars. Containers call back to
// this URL to ask whether a user may SSH in (see ssh-access router).
const MANAGER_DEFAULTS = [
{
key: 'MANAGER_URL',
value: '',
description:
'Public base URL of this manager, reachable from containers (e.g. https://manager.example.com). Required for per-container SSH access enforcement.',
},
];

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface) {
const [rows] = await queryInterface.sequelize.query(
`SELECT value FROM "Settings" WHERE key = 'default_container_env_vars'`,
);

let existing = [];
if (rows.length > 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() } },
);
},
};
89 changes: 83 additions & 6 deletions create-a-container/models/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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<boolean>}
*/
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<string>}
*/
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<string>}
*/
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`
Expand All @@ -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;

Expand All @@ -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.
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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<object>} 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -383,5 +458,7 @@ module.exports = (sequelize, DataTypes) => {
}
]
});
Container.RESERVED_ENV_KEYS = RESERVED_ENV_KEYS;
Container.USERNAME_RE = USERNAME_RE;
return Container;
};
Loading
Loading