Workspace-owned deploy infra so AI agents can create and destroy
Coolify resources without ever touching the root admin token.
vibn_workspaces
+ coolify_server_uuid, coolify_destination_uuid
+ coolify_environment_name (default "production")
+ coolify_private_key_uuid, gitea_bot_ssh_key_id
ensureWorkspaceProvisioned
+ generates an ed25519 keypair per workspace
+ pushes pubkey to the Gitea bot user (read/write scoped by team)
+ registers privkey in Coolify as a reusable deploy key
New endpoints under /api/workspaces/[slug]/
apps/ POST (private-deploy-key from Gitea repo)
apps/[uuid] PATCH, DELETE?confirm=<name>
apps/[uuid]/domains GET, PATCH (policy: *.{ws}.vibnai.com only)
databases/ GET, POST (8 types incl. postgres, clickhouse, dragonfly)
databases/[uuid] GET, PATCH, DELETE?confirm=<name>
auth/ GET, POST (Pocketbase, Authentik, Keycloak, Pocket-ID, Logto, Supertokens)
auth/[uuid] DELETE?confirm=<name>
MCP (/api/mcp) gains 15 new tools that mirror the REST surface and
enforce the same workspace tenancy + delete-confirm guard.
Safety: destructive ops require ?confirm=<exact-resource-name>; volumes
are kept by default (pass delete_volumes=true to drop).
Made-with: Cursor
81 lines
2.8 KiB
TypeScript
81 lines
2.8 KiB
TypeScript
/**
|
|
* ed25519 SSH keypair generation for per-workspace Coolify deploy keys.
|
|
*
|
|
* We generate once at provisioning time:
|
|
* - public key in OpenSSH format (for Gitea's SSH keys API)
|
|
* - private key in PKCS8 PEM (Coolify accepts this via /security/keys)
|
|
*
|
|
* Keys live in memory just long enough to be pushed to Gitea and Coolify;
|
|
* neither is ever persisted by Vibn directly (Coolify holds the private
|
|
* key, Gitea holds the public key). We keep the Coolify key uuid and the
|
|
* Gitea key id on vibn_workspaces so we can rotate them later.
|
|
*/
|
|
|
|
import { generateKeyPairSync, createPublicKey, type KeyObject } from 'crypto';
|
|
|
|
export interface Ed25519Keypair {
|
|
/** PKCS8 PEM — what Coolify's POST /security/keys wants. */
|
|
privateKeyPem: string;
|
|
/** OpenSSH public-key line: "ssh-ed25519 AAAA… <comment>" */
|
|
publicKeyOpenSsh: string;
|
|
/** SHA256 fingerprint string (without the trailing "=") */
|
|
fingerprint: string;
|
|
}
|
|
|
|
export function generateEd25519Keypair(comment = ''): Ed25519Keypair {
|
|
const { privateKey, publicKey } = generateKeyPairSync('ed25519', {
|
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
});
|
|
const pubKeyObj = createPublicKey(publicKey);
|
|
const publicKeyOpenSsh = ed25519PublicKeyToOpenSsh(pubKeyObj, comment);
|
|
return {
|
|
privateKeyPem: privateKey,
|
|
publicKeyOpenSsh,
|
|
fingerprint: opensshFingerprint(publicKeyOpenSsh),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert a Node ed25519 public KeyObject into an OpenSSH-format line.
|
|
* Wire format for the base64 blob:
|
|
* uint32 len=11 | "ssh-ed25519" | uint32 len=32 | raw-pubkey-bytes
|
|
*/
|
|
function ed25519PublicKeyToOpenSsh(publicKey: KeyObject, comment: string): string {
|
|
const jwk = publicKey.export({ format: 'jwk' }) as { x?: string };
|
|
if (!jwk.x) {
|
|
throw new Error('public key has no jwk.x component — not ed25519?');
|
|
}
|
|
const rawKey = Buffer.from(jwk.x, 'base64url');
|
|
if (rawKey.length !== 32) {
|
|
throw new Error(`expected 32-byte ed25519 pubkey, got ${rawKey.length}`);
|
|
}
|
|
const keyTypeBytes = Buffer.from('ssh-ed25519');
|
|
const blob = Buffer.concat([
|
|
uint32BE(keyTypeBytes.length),
|
|
keyTypeBytes,
|
|
uint32BE(rawKey.length),
|
|
rawKey,
|
|
]).toString('base64');
|
|
const tail = comment ? ` ${comment}` : '';
|
|
return `ssh-ed25519 ${blob}${tail}`;
|
|
}
|
|
|
|
function uint32BE(n: number): Buffer {
|
|
const b = Buffer.alloc(4);
|
|
b.writeUInt32BE(n);
|
|
return b;
|
|
}
|
|
|
|
/**
|
|
* OpenSSH `ssh-keygen -lf`-style SHA256 fingerprint of a public key line.
|
|
*/
|
|
function opensshFingerprint(publicKeyLine: string): string {
|
|
const b64 = publicKeyLine.trim().split(/\s+/)[1];
|
|
if (!b64) return '';
|
|
const raw = Buffer.from(b64, 'base64');
|
|
const { createHash } = require('crypto') as typeof import('crypto');
|
|
const fp = createHash('sha256').update(raw).digest('base64');
|
|
return `SHA256:${fp.replace(/=+$/, '')}`;
|
|
}
|