@matthesketh/fleet 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/data/registry.example.json +13 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +113 -0
- package/dist/commands/add.d.ts +1 -0
- package/dist/commands/add.js +95 -0
- package/dist/commands/deploy.d.ts +1 -0
- package/dist/commands/deploy.js +53 -0
- package/dist/commands/git.d.ts +1 -0
- package/dist/commands/git.js +278 -0
- package/dist/commands/health.d.ts +1 -0
- package/dist/commands/health.js +60 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +157 -0
- package/dist/commands/install-mcp.d.ts +1 -0
- package/dist/commands/install-mcp.js +55 -0
- package/dist/commands/list.d.ts +1 -0
- package/dist/commands/list.js +20 -0
- package/dist/commands/logs.d.ts +1 -0
- package/dist/commands/logs.js +32 -0
- package/dist/commands/nginx.d.ts +1 -0
- package/dist/commands/nginx.js +94 -0
- package/dist/commands/remove.d.ts +1 -0
- package/dist/commands/remove.js +28 -0
- package/dist/commands/restart.d.ts +1 -0
- package/dist/commands/restart.js +22 -0
- package/dist/commands/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +268 -0
- package/dist/commands/start.d.ts +1 -0
- package/dist/commands/start.js +22 -0
- package/dist/commands/status.d.ts +14 -0
- package/dist/commands/status.js +70 -0
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +22 -0
- package/dist/commands/watchdog.d.ts +1 -0
- package/dist/commands/watchdog.js +100 -0
- package/dist/core/docker.d.ts +15 -0
- package/dist/core/docker.js +72 -0
- package/dist/core/errors.d.ts +20 -0
- package/dist/core/errors.js +40 -0
- package/dist/core/exec.d.ts +14 -0
- package/dist/core/exec.js +30 -0
- package/dist/core/git-onboard.d.ts +11 -0
- package/dist/core/git-onboard.js +149 -0
- package/dist/core/git.d.ts +36 -0
- package/dist/core/git.js +155 -0
- package/dist/core/github.d.ts +22 -0
- package/dist/core/github.js +92 -0
- package/dist/core/health.d.ts +29 -0
- package/dist/core/health.js +56 -0
- package/dist/core/nginx.d.ts +17 -0
- package/dist/core/nginx.js +59 -0
- package/dist/core/registry.d.ts +38 -0
- package/dist/core/registry.js +47 -0
- package/dist/core/secrets-ops.d.ts +37 -0
- package/dist/core/secrets-ops.js +331 -0
- package/dist/core/secrets-validate.d.ts +8 -0
- package/dist/core/secrets-validate.js +81 -0
- package/dist/core/secrets.d.ts +36 -0
- package/dist/core/secrets.js +191 -0
- package/dist/core/systemd.d.ts +23 -0
- package/dist/core/systemd.js +106 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +18 -0
- package/dist/mcp/git-tools.d.ts +2 -0
- package/dist/mcp/git-tools.js +148 -0
- package/dist/mcp/secrets-tools.d.ts +2 -0
- package/dist/mcp/secrets-tools.js +67 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +179 -0
- package/dist/templates/gitignore.d.ts +3 -0
- package/dist/templates/gitignore.js +89 -0
- package/dist/templates/nginx.d.ts +8 -0
- package/dist/templates/nginx.js +111 -0
- package/dist/templates/systemd.d.ts +9 -0
- package/dist/templates/systemd.js +26 -0
- package/dist/templates/unseal.d.ts +1 -0
- package/dist/templates/unseal.js +22 -0
- package/dist/tui/app.d.ts +1 -0
- package/dist/tui/app.js +9 -0
- package/dist/tui/components/AppList.d.ts +12 -0
- package/dist/tui/components/AppList.js +32 -0
- package/dist/tui/components/Confirm.d.ts +2 -0
- package/dist/tui/components/Confirm.js +10 -0
- package/dist/tui/components/Header.d.ts +6 -0
- package/dist/tui/components/Header.js +16 -0
- package/dist/tui/components/KeyHint.d.ts +2 -0
- package/dist/tui/components/KeyHint.js +55 -0
- package/dist/tui/components/StatusBadge.d.ts +7 -0
- package/dist/tui/components/StatusBadge.js +8 -0
- package/dist/tui/exec-bridge.d.ts +11 -0
- package/dist/tui/exec-bridge.js +57 -0
- package/dist/tui/hooks/use-fleet-data.d.ts +9 -0
- package/dist/tui/hooks/use-fleet-data.js +30 -0
- package/dist/tui/hooks/use-health.d.ts +9 -0
- package/dist/tui/hooks/use-health.js +29 -0
- package/dist/tui/hooks/use-interval.d.ts +1 -0
- package/dist/tui/hooks/use-interval.js +13 -0
- package/dist/tui/hooks/use-keyboard.d.ts +1 -0
- package/dist/tui/hooks/use-keyboard.js +44 -0
- package/dist/tui/hooks/use-secrets.d.ts +47 -0
- package/dist/tui/hooks/use-secrets.js +152 -0
- package/dist/tui/router.d.ts +2 -0
- package/dist/tui/router.js +65 -0
- package/dist/tui/state.d.ts +12 -0
- package/dist/tui/state.js +83 -0
- package/dist/tui/theme.d.ts +11 -0
- package/dist/tui/theme.js +23 -0
- package/dist/tui/types.d.ts +41 -0
- package/dist/tui/types.js +1 -0
- package/dist/tui/views/AppDetail.d.ts +2 -0
- package/dist/tui/views/AppDetail.js +72 -0
- package/dist/tui/views/Dashboard.d.ts +2 -0
- package/dist/tui/views/Dashboard.js +29 -0
- package/dist/tui/views/HealthView.d.ts +2 -0
- package/dist/tui/views/HealthView.js +28 -0
- package/dist/tui/views/LogsView.d.ts +2 -0
- package/dist/tui/views/LogsView.js +71 -0
- package/dist/tui/views/SecretEdit.d.ts +2 -0
- package/dist/tui/views/SecretEdit.js +53 -0
- package/dist/tui/views/SecretsView.d.ts +2 -0
- package/dist/tui/views/SecretsView.js +108 -0
- package/dist/ui/confirm.d.ts +1 -0
- package/dist/ui/confirm.js +15 -0
- package/dist/ui/output.d.ts +27 -0
- package/dist/ui/output.js +61 -0
- package/package.json +64 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { exec } from './exec.js';
|
|
2
|
+
import { getServiceStatus, getMultipleServiceStatuses, systemdAvailable } from './systemd.js';
|
|
3
|
+
import { listContainers } from './docker.js';
|
|
4
|
+
export function checkHealth(app, prefetched) {
|
|
5
|
+
const systemd = prefetched !== undefined
|
|
6
|
+
? prefetched.serviceStatus
|
|
7
|
+
: (systemdAvailable() ? getServiceStatus(app.serviceName) : null);
|
|
8
|
+
const allContainers = prefetched !== undefined
|
|
9
|
+
? prefetched.containers
|
|
10
|
+
: listContainers();
|
|
11
|
+
const containers = app.containers.map(name => {
|
|
12
|
+
const c = allContainers.find(ac => ac.name === name);
|
|
13
|
+
return {
|
|
14
|
+
name,
|
|
15
|
+
running: c !== undefined && c.status.startsWith('Up'),
|
|
16
|
+
health: c?.health ?? 'not found',
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
let http = null;
|
|
20
|
+
if (app.port) {
|
|
21
|
+
http = checkHttp(app.port, app.healthPath);
|
|
22
|
+
}
|
|
23
|
+
const systemdOk = systemd ? systemd.active : true; // skip if unavailable
|
|
24
|
+
const containersOk = containers.length > 0 && containers.every(c => c.running);
|
|
25
|
+
const httpOk = http === null || http.ok;
|
|
26
|
+
const overall = systemdOk && containersOk && httpOk ? 'healthy'
|
|
27
|
+
: !containersOk ? 'down'
|
|
28
|
+
: 'degraded';
|
|
29
|
+
return {
|
|
30
|
+
app: app.name,
|
|
31
|
+
systemd: { ok: systemd?.active ?? false, state: systemd?.state ?? 'n/a' },
|
|
32
|
+
containers,
|
|
33
|
+
http,
|
|
34
|
+
overall,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function checkHttp(port, healthPath) {
|
|
38
|
+
const path = healthPath ?? '/health';
|
|
39
|
+
const result = exec(`curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://127.0.0.1:${port}${path}`, { timeout: 10_000 });
|
|
40
|
+
const status = parseInt(result.stdout, 10);
|
|
41
|
+
if (!isNaN(status) && status > 0) {
|
|
42
|
+
return { ok: status >= 200 && status < 500, status, error: null };
|
|
43
|
+
}
|
|
44
|
+
return { ok: false, status: null, error: result.stderr || 'Connection failed' };
|
|
45
|
+
}
|
|
46
|
+
export function checkAllHealth(apps) {
|
|
47
|
+
const allContainers = listContainers();
|
|
48
|
+
const hasSystemd = systemdAvailable();
|
|
49
|
+
const serviceStatuses = hasSystemd
|
|
50
|
+
? getMultipleServiceStatuses(apps.map(a => a.serviceName))
|
|
51
|
+
: new Map();
|
|
52
|
+
return apps.map(app => checkHealth(app, {
|
|
53
|
+
containers: allContainers,
|
|
54
|
+
serviceStatus: serviceStatuses.get(app.serviceName) ?? null,
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface NginxSite {
|
|
2
|
+
domain: string;
|
|
3
|
+
configFile: string;
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
ssl: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function listSites(): NginxSite[];
|
|
8
|
+
export declare function installConfig(domain: string, content: string): void;
|
|
9
|
+
export declare function removeConfig(domain: string): boolean;
|
|
10
|
+
export declare function testConfig(): {
|
|
11
|
+
ok: boolean;
|
|
12
|
+
output: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function reload(): boolean;
|
|
15
|
+
export declare function readConfig(domain: string): string | null;
|
|
16
|
+
export declare function extractPortFromConfig(content: string): number | null;
|
|
17
|
+
export declare function extractDomainsFromConfig(content: string): string[];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { exec } from './exec.js';
|
|
3
|
+
const SITES_AVAILABLE = '/etc/nginx/sites-available';
|
|
4
|
+
const SITES_ENABLED = '/etc/nginx/sites-enabled';
|
|
5
|
+
export function listSites() {
|
|
6
|
+
if (!existsSync(SITES_AVAILABLE))
|
|
7
|
+
return [];
|
|
8
|
+
const files = readdirSync(SITES_AVAILABLE).filter(f => f.endsWith('.conf') && !f.startsWith('default'));
|
|
9
|
+
return files.map(file => {
|
|
10
|
+
const content = readFileSync(`${SITES_AVAILABLE}/${file}`, 'utf-8');
|
|
11
|
+
const domain = file.replace('.conf', '');
|
|
12
|
+
const enabled = existsSync(`${SITES_ENABLED}/${file}`);
|
|
13
|
+
const ssl = content.includes('ssl_certificate') || content.includes('listen 443');
|
|
14
|
+
return { domain, configFile: file, enabled, ssl };
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export function installConfig(domain, content) {
|
|
18
|
+
const filename = `${domain}.conf`;
|
|
19
|
+
writeFileSync(`${SITES_AVAILABLE}/${filename}`, content);
|
|
20
|
+
const enabledPath = `${SITES_ENABLED}/${filename}`;
|
|
21
|
+
if (!existsSync(enabledPath)) {
|
|
22
|
+
exec(`ln -sf ${SITES_AVAILABLE}/${filename} ${enabledPath}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function removeConfig(domain) {
|
|
26
|
+
const filename = `${domain}.conf`;
|
|
27
|
+
const available = `${SITES_AVAILABLE}/${filename}`;
|
|
28
|
+
const enabled = `${SITES_ENABLED}/${filename}`;
|
|
29
|
+
if (existsSync(enabled))
|
|
30
|
+
unlinkSync(enabled);
|
|
31
|
+
if (existsSync(available)) {
|
|
32
|
+
unlinkSync(available);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
export function testConfig() {
|
|
38
|
+
const result = exec('nginx -t 2>&1', { timeout: 10_000 });
|
|
39
|
+
return { ok: result.ok || result.stderr.includes('successful'), output: result.stderr || result.stdout };
|
|
40
|
+
}
|
|
41
|
+
export function reload() {
|
|
42
|
+
return exec('systemctl reload nginx', { timeout: 10_000 }).ok;
|
|
43
|
+
}
|
|
44
|
+
export function readConfig(domain) {
|
|
45
|
+
const path = `${SITES_AVAILABLE}/${domain}.conf`;
|
|
46
|
+
if (!existsSync(path))
|
|
47
|
+
return null;
|
|
48
|
+
return readFileSync(path, 'utf-8');
|
|
49
|
+
}
|
|
50
|
+
export function extractPortFromConfig(content) {
|
|
51
|
+
const match = content.match(/proxy_pass\s+https?:\/\/(?:127\.0\.0\.1|localhost):(\d+)/);
|
|
52
|
+
return match ? parseInt(match[1], 10) : null;
|
|
53
|
+
}
|
|
54
|
+
export function extractDomainsFromConfig(content) {
|
|
55
|
+
const match = content.match(/server_name\s+([^;]+);/);
|
|
56
|
+
if (!match)
|
|
57
|
+
return [];
|
|
58
|
+
return match[1].split(/\s+/).filter(d => d && d !== '_');
|
|
59
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface AppEntry {
|
|
2
|
+
name: string;
|
|
3
|
+
displayName: string;
|
|
4
|
+
composePath: string;
|
|
5
|
+
composeFile: string | null;
|
|
6
|
+
serviceName: string;
|
|
7
|
+
domains: string[];
|
|
8
|
+
port: number | null;
|
|
9
|
+
usesSharedDb: boolean;
|
|
10
|
+
type: 'spa' | 'proxy' | 'nextjs' | 'service';
|
|
11
|
+
containers: string[];
|
|
12
|
+
dependsOnDatabases: boolean;
|
|
13
|
+
healthPath?: string;
|
|
14
|
+
secretsManaged?: boolean;
|
|
15
|
+
gitRepo?: string;
|
|
16
|
+
gitRemoteUrl?: string;
|
|
17
|
+
gitOnboardedAt?: string;
|
|
18
|
+
registeredAt: string;
|
|
19
|
+
}
|
|
20
|
+
export interface Registry {
|
|
21
|
+
version: number;
|
|
22
|
+
apps: AppEntry[];
|
|
23
|
+
infrastructure: {
|
|
24
|
+
databases: {
|
|
25
|
+
serviceName: string;
|
|
26
|
+
composePath: string;
|
|
27
|
+
};
|
|
28
|
+
nginx: {
|
|
29
|
+
configPath: string;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export declare function load(): Registry;
|
|
34
|
+
export declare function save(reg: Registry): void;
|
|
35
|
+
export declare function findApp(reg: Registry, name: string): AppEntry | undefined;
|
|
36
|
+
export declare function addApp(reg: Registry, app: AppEntry): Registry;
|
|
37
|
+
export declare function removeApp(reg: Registry, name: string): Registry;
|
|
38
|
+
export declare function registryPath(): string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const REGISTRY_PATH = join(__dirname, '..', '..', 'data', 'registry.json');
|
|
6
|
+
function defaultRegistry() {
|
|
7
|
+
return {
|
|
8
|
+
version: 1,
|
|
9
|
+
apps: [],
|
|
10
|
+
infrastructure: {
|
|
11
|
+
databases: { serviceName: 'docker-databases', composePath: '/home/matt/docker-databases' },
|
|
12
|
+
nginx: { configPath: '/etc/nginx' },
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function load() {
|
|
17
|
+
if (!existsSync(REGISTRY_PATH))
|
|
18
|
+
return defaultRegistry();
|
|
19
|
+
const raw = readFileSync(REGISTRY_PATH, 'utf-8');
|
|
20
|
+
return JSON.parse(raw);
|
|
21
|
+
}
|
|
22
|
+
export function save(reg) {
|
|
23
|
+
const dir = dirname(REGISTRY_PATH);
|
|
24
|
+
if (!existsSync(dir))
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
writeFileSync(REGISTRY_PATH, JSON.stringify(reg, null, 2) + '\n');
|
|
27
|
+
}
|
|
28
|
+
export function findApp(reg, name) {
|
|
29
|
+
return reg.apps.find(a => a.name === name || a.serviceName === name || a.displayName.toLowerCase() === name.toLowerCase());
|
|
30
|
+
}
|
|
31
|
+
export function addApp(reg, app) {
|
|
32
|
+
const existing = reg.apps.findIndex(a => a.name === app.name);
|
|
33
|
+
if (existing >= 0) {
|
|
34
|
+
reg.apps[existing] = app;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
reg.apps.push(app);
|
|
38
|
+
}
|
|
39
|
+
return reg;
|
|
40
|
+
}
|
|
41
|
+
export function removeApp(reg, name) {
|
|
42
|
+
reg.apps = reg.apps.filter(a => a.name !== name);
|
|
43
|
+
return reg;
|
|
44
|
+
}
|
|
45
|
+
export function registryPath() {
|
|
46
|
+
return REGISTRY_PATH;
|
|
47
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface SealValidation {
|
|
2
|
+
added: string[];
|
|
3
|
+
removed: string[];
|
|
4
|
+
unchanged: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function validateBeforeSeal(app: string, newContent: string): SealValidation;
|
|
7
|
+
export declare function safeSealApp(app: string, content: string, sourceFile: string): SealValidation;
|
|
8
|
+
export declare function safeSealDbSecrets(app: string, secretsMap: Record<string, string>, sourceDir: string): SealValidation;
|
|
9
|
+
export declare function setSecret(app: string, key: string, value: string): void;
|
|
10
|
+
export declare function getSecret(app: string, key: string): string | null;
|
|
11
|
+
export declare function importEnvFile(app: string, path: string): number;
|
|
12
|
+
export declare function importDbSecrets(app: string, dir: string): number;
|
|
13
|
+
export declare function exportApp(app: string): string;
|
|
14
|
+
export interface DriftResult {
|
|
15
|
+
app: string;
|
|
16
|
+
status: 'in-sync' | 'drifted' | 'missing-runtime';
|
|
17
|
+
addedKeys: string[];
|
|
18
|
+
removedKeys: string[];
|
|
19
|
+
changedKeys: string[];
|
|
20
|
+
}
|
|
21
|
+
export declare function detectDrift(app?: string): DriftResult[];
|
|
22
|
+
export declare function unsealAll(): void;
|
|
23
|
+
export declare function sealFromRuntime(app?: string): string[];
|
|
24
|
+
export declare function rotateKey(): {
|
|
25
|
+
oldPubkey: string;
|
|
26
|
+
newPubkey: string;
|
|
27
|
+
appsRotated: string[];
|
|
28
|
+
};
|
|
29
|
+
export declare function getStatus(): {
|
|
30
|
+
initialized: boolean;
|
|
31
|
+
sealed: boolean;
|
|
32
|
+
keyPath: string;
|
|
33
|
+
vaultDir: string;
|
|
34
|
+
runtimeDir: string;
|
|
35
|
+
appCount: number;
|
|
36
|
+
totalKeys: number;
|
|
37
|
+
};
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, chmodSync, mkdirSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import { validateAll } from './secrets-validate.js';
|
|
5
|
+
import { SecretsError } from './errors.js';
|
|
6
|
+
import { KEY_PATH, VAULT_DIR, RUNTIME_DIR, loadManifest, saveManifest, decryptApp, parseSecretsBundle, sealApp, sealDbSecrets, ageEncrypt, ageDecryptFile, getPublicKey, isInitialized, isSealed, backupVaultFile, restoreVaultFile, removeBackup, } from './secrets.js';
|
|
7
|
+
// --- Helpers ---
|
|
8
|
+
function parseEnvKeys(content) {
|
|
9
|
+
return content.split('\n')
|
|
10
|
+
.filter(l => l.includes('=') && !l.startsWith('#') && l.trim())
|
|
11
|
+
.map(l => l.substring(0, l.indexOf('=')));
|
|
12
|
+
}
|
|
13
|
+
export function validateBeforeSeal(app, newContent) {
|
|
14
|
+
const manifest = loadManifest();
|
|
15
|
+
const entry = manifest.apps[app];
|
|
16
|
+
// New app — no previous data to compare
|
|
17
|
+
if (!entry)
|
|
18
|
+
return { added: parseEnvKeys(newContent), removed: [], unchanged: [] };
|
|
19
|
+
const oldPlaintext = decryptApp(app);
|
|
20
|
+
let oldKeys;
|
|
21
|
+
let newKeys;
|
|
22
|
+
if (entry.type === 'env') {
|
|
23
|
+
oldKeys = parseEnvKeys(oldPlaintext);
|
|
24
|
+
newKeys = parseEnvKeys(newContent);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
oldKeys = Object.keys(parseSecretsBundle(oldPlaintext));
|
|
28
|
+
newKeys = Object.keys(parseSecretsBundle(newContent));
|
|
29
|
+
}
|
|
30
|
+
const oldSet = new Set(oldKeys);
|
|
31
|
+
const newSet = new Set(newKeys);
|
|
32
|
+
const added = newKeys.filter(k => !oldSet.has(k));
|
|
33
|
+
const removed = oldKeys.filter(k => !newSet.has(k));
|
|
34
|
+
const unchanged = oldKeys.filter(k => newSet.has(k));
|
|
35
|
+
// Reject if >50% of keys would be dropped (protects against accidental wipes)
|
|
36
|
+
if (oldKeys.length > 0 && removed.length > oldKeys.length * 0.5) {
|
|
37
|
+
throw new SecretsError(`Seal rejected for ${app}: would remove ${removed.length}/${oldKeys.length} keys (${removed.join(', ')}). ` +
|
|
38
|
+
`This looks like an accidental wipe. Use importEnvFile to force-replace.`);
|
|
39
|
+
}
|
|
40
|
+
return { added, removed, unchanged };
|
|
41
|
+
}
|
|
42
|
+
// --- Phase 8: Safe seal wrappers ---
|
|
43
|
+
export function safeSealApp(app, content, sourceFile) {
|
|
44
|
+
const validation = validateBeforeSeal(app, content);
|
|
45
|
+
backupVaultFile(app);
|
|
46
|
+
try {
|
|
47
|
+
sealApp(app, content, sourceFile);
|
|
48
|
+
removeBackup(app);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
restoreVaultFile(app);
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
return validation;
|
|
55
|
+
}
|
|
56
|
+
export function safeSealDbSecrets(app, secretsMap, sourceDir) {
|
|
57
|
+
// Build the bundle content for validation
|
|
58
|
+
const SECRET_DELIMITER = '---SECRET:';
|
|
59
|
+
const filenames = Object.keys(secretsMap).sort();
|
|
60
|
+
const parts = filenames.map(f => `${SECRET_DELIMITER}${f}---\n${secretsMap[f]}`);
|
|
61
|
+
const bundleContent = parts.join('\n');
|
|
62
|
+
const validation = validateBeforeSeal(app, bundleContent);
|
|
63
|
+
backupVaultFile(app);
|
|
64
|
+
try {
|
|
65
|
+
sealDbSecrets(app, secretsMap, sourceDir);
|
|
66
|
+
removeBackup(app);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
restoreVaultFile(app);
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
return validation;
|
|
73
|
+
}
|
|
74
|
+
export function setSecret(app, key, value) {
|
|
75
|
+
const plaintext = decryptApp(app);
|
|
76
|
+
const manifest = loadManifest();
|
|
77
|
+
const entry = manifest.apps[app];
|
|
78
|
+
if (entry.type !== 'env')
|
|
79
|
+
throw new SecretsError(`Cannot set key/value on secrets-dir type for ${app}`);
|
|
80
|
+
const lines = plaintext.split('\n');
|
|
81
|
+
let found = false;
|
|
82
|
+
const updated = lines.map(line => {
|
|
83
|
+
const eqIdx = line.indexOf('=');
|
|
84
|
+
if (eqIdx > 0 && line.substring(0, eqIdx) === key) {
|
|
85
|
+
found = true;
|
|
86
|
+
return `${key}=${value}`;
|
|
87
|
+
}
|
|
88
|
+
return line;
|
|
89
|
+
});
|
|
90
|
+
if (!found)
|
|
91
|
+
updated.push(`${key}=${value}`);
|
|
92
|
+
safeSealApp(app, updated.join('\n'), entry.sourceFile);
|
|
93
|
+
}
|
|
94
|
+
export function getSecret(app, key) {
|
|
95
|
+
const plaintext = decryptApp(app);
|
|
96
|
+
const manifest = loadManifest();
|
|
97
|
+
const entry = manifest.apps[app];
|
|
98
|
+
if (entry.type === 'env') {
|
|
99
|
+
for (const line of plaintext.split('\n')) {
|
|
100
|
+
const eqIdx = line.indexOf('=');
|
|
101
|
+
if (eqIdx > 0 && line.substring(0, eqIdx) === key) {
|
|
102
|
+
return line.substring(eqIdx + 1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const files = parseSecretsBundle(plaintext);
|
|
108
|
+
return files[key] ?? null;
|
|
109
|
+
}
|
|
110
|
+
export function importEnvFile(app, path) {
|
|
111
|
+
if (!existsSync(path))
|
|
112
|
+
throw new SecretsError(`File not found: ${path}`);
|
|
113
|
+
const content = readFileSync(path, 'utf-8');
|
|
114
|
+
// importEnvFile is an explicit replace — bypass validation, but still backup
|
|
115
|
+
backupVaultFile(app);
|
|
116
|
+
try {
|
|
117
|
+
sealApp(app, content, path);
|
|
118
|
+
removeBackup(app);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
restoreVaultFile(app);
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
const manifest = loadManifest();
|
|
125
|
+
return manifest.apps[app].keyCount;
|
|
126
|
+
}
|
|
127
|
+
export function importDbSecrets(app, dir) {
|
|
128
|
+
if (!existsSync(dir))
|
|
129
|
+
throw new SecretsError(`Directory not found: ${dir}`);
|
|
130
|
+
const stat = statSync(dir);
|
|
131
|
+
if (!stat.isDirectory())
|
|
132
|
+
throw new SecretsError(`Not a directory: ${dir}`);
|
|
133
|
+
const files = readdirSync(dir).filter(f => !f.startsWith('.'));
|
|
134
|
+
const secretsMap = {};
|
|
135
|
+
for (const file of files) {
|
|
136
|
+
secretsMap[file] = readFileSync(join(dir, file), 'utf-8');
|
|
137
|
+
}
|
|
138
|
+
// importDbSecrets is an explicit replace — bypass validation, but still backup
|
|
139
|
+
backupVaultFile(app);
|
|
140
|
+
try {
|
|
141
|
+
sealDbSecrets(app, secretsMap, dir);
|
|
142
|
+
removeBackup(app);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
restoreVaultFile(app);
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
return files.length;
|
|
149
|
+
}
|
|
150
|
+
export function exportApp(app) {
|
|
151
|
+
return decryptApp(app);
|
|
152
|
+
}
|
|
153
|
+
export function detectDrift(app) {
|
|
154
|
+
const manifest = loadManifest();
|
|
155
|
+
const apps = app ? [app] : Object.keys(manifest.apps);
|
|
156
|
+
const results = [];
|
|
157
|
+
for (const a of apps) {
|
|
158
|
+
const entry = manifest.apps[a];
|
|
159
|
+
if (!entry) {
|
|
160
|
+
results.push({ app: a, status: 'missing-runtime', addedKeys: [], removedKeys: [], changedKeys: [] });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (entry.type === 'env') {
|
|
164
|
+
const runtimePath = join(RUNTIME_DIR, a, '.env');
|
|
165
|
+
if (!existsSync(runtimePath)) {
|
|
166
|
+
results.push({ app: a, status: 'missing-runtime', addedKeys: [], removedKeys: [], changedKeys: [] });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const vaultPlaintext = decryptApp(a);
|
|
170
|
+
const runtimeContent = readFileSync(runtimePath, 'utf-8');
|
|
171
|
+
const vaultMap = parseEnvMap(vaultPlaintext);
|
|
172
|
+
const runtimeMap = parseEnvMap(runtimeContent);
|
|
173
|
+
const addedKeys = Object.keys(runtimeMap).filter(k => !(k in vaultMap));
|
|
174
|
+
const removedKeys = Object.keys(vaultMap).filter(k => !(k in runtimeMap));
|
|
175
|
+
const changedKeys = Object.keys(vaultMap).filter(k => k in runtimeMap && vaultMap[k] !== runtimeMap[k]);
|
|
176
|
+
const status = (addedKeys.length || removedKeys.length || changedKeys.length) ? 'drifted' : 'in-sync';
|
|
177
|
+
results.push({ app: a, status, addedKeys, removedKeys, changedKeys });
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
const runtimeDir = join(RUNTIME_DIR, a, 'secrets');
|
|
181
|
+
if (!existsSync(runtimeDir)) {
|
|
182
|
+
results.push({ app: a, status: 'missing-runtime', addedKeys: [], removedKeys: [], changedKeys: [] });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const vaultPlaintext = decryptApp(a);
|
|
186
|
+
const vaultFiles = parseSecretsBundle(vaultPlaintext);
|
|
187
|
+
const runtimeFiles = readdirSync(runtimeDir);
|
|
188
|
+
const runtimeMap = {};
|
|
189
|
+
for (const f of runtimeFiles) {
|
|
190
|
+
runtimeMap[f] = readFileSync(join(runtimeDir, f), 'utf-8');
|
|
191
|
+
}
|
|
192
|
+
const addedKeys = runtimeFiles.filter(f => !(f in vaultFiles));
|
|
193
|
+
const removedKeys = Object.keys(vaultFiles).filter(f => !(f in runtimeMap));
|
|
194
|
+
const changedKeys = Object.keys(vaultFiles).filter(f => f in runtimeMap && vaultFiles[f] !== runtimeMap[f]);
|
|
195
|
+
const status = (addedKeys.length || removedKeys.length || changedKeys.length) ? 'drifted' : 'in-sync';
|
|
196
|
+
results.push({ app: a, status, addedKeys, removedKeys, changedKeys });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return results;
|
|
200
|
+
}
|
|
201
|
+
function parseEnvMap(content) {
|
|
202
|
+
const map = {};
|
|
203
|
+
for (const line of content.split('\n')) {
|
|
204
|
+
if (!line.trim() || line.startsWith('#'))
|
|
205
|
+
continue;
|
|
206
|
+
const eqIdx = line.indexOf('=');
|
|
207
|
+
if (eqIdx > 0) {
|
|
208
|
+
map[line.substring(0, eqIdx)] = line.substring(eqIdx + 1);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return map;
|
|
212
|
+
}
|
|
213
|
+
// --- Phase 4: Improved unseal (validate before write) ---
|
|
214
|
+
export function unsealAll() {
|
|
215
|
+
const manifest = loadManifest();
|
|
216
|
+
// Phase 4: Decrypt all apps first and validate BEFORE writing to runtime
|
|
217
|
+
const decrypted = {};
|
|
218
|
+
for (const [app, entry] of Object.entries(manifest.apps)) {
|
|
219
|
+
decrypted[app] = ageDecryptFile(join(VAULT_DIR, entry.encryptedFile));
|
|
220
|
+
}
|
|
221
|
+
// Validate before writing — catch missing secrets before runtime gets partial data
|
|
222
|
+
const results = validateAll();
|
|
223
|
+
let hasMissing = false;
|
|
224
|
+
for (const r of results) {
|
|
225
|
+
if (r.missing.length > 0) {
|
|
226
|
+
process.stderr.write(`[fleet-unseal] ERROR: ${r.app} missing secrets: ${r.missing.join(', ')}\n`);
|
|
227
|
+
hasMissing = true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (hasMissing) {
|
|
231
|
+
throw new SecretsError('Unseal aborted — some secrets are missing from the vault. Runtime was NOT modified. Run "fleet secrets validate" for details.');
|
|
232
|
+
}
|
|
233
|
+
// All valid — now write to runtime
|
|
234
|
+
if (!existsSync(RUNTIME_DIR)) {
|
|
235
|
+
mkdirSync(RUNTIME_DIR, { mode: 0o700, recursive: true });
|
|
236
|
+
}
|
|
237
|
+
for (const [app, entry] of Object.entries(manifest.apps)) {
|
|
238
|
+
const plaintext = decrypted[app];
|
|
239
|
+
if (entry.type === 'env') {
|
|
240
|
+
const appDir = join(RUNTIME_DIR, app);
|
|
241
|
+
if (!existsSync(appDir))
|
|
242
|
+
mkdirSync(appDir, { recursive: true, mode: 0o700 });
|
|
243
|
+
const envPath = join(appDir, '.env');
|
|
244
|
+
writeFileSync(envPath, plaintext);
|
|
245
|
+
chmodSync(envPath, 0o600);
|
|
246
|
+
}
|
|
247
|
+
else if (entry.type === 'secrets-dir') {
|
|
248
|
+
const secretsDir = join(RUNTIME_DIR, app, 'secrets');
|
|
249
|
+
if (!existsSync(secretsDir))
|
|
250
|
+
mkdirSync(secretsDir, { recursive: true, mode: 0o755 });
|
|
251
|
+
const parsed = parseSecretsBundle(plaintext);
|
|
252
|
+
for (const [filename, content] of Object.entries(parsed)) {
|
|
253
|
+
const fpath = join(secretsDir, filename);
|
|
254
|
+
writeFileSync(fpath, content);
|
|
255
|
+
// 0644: docker compose secrets bind-mounts files into containers where
|
|
256
|
+
// non-root processes (e.g. mongodb uid 999) need read access
|
|
257
|
+
chmodSync(fpath, 0o644);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
export function sealFromRuntime(app) {
|
|
263
|
+
const manifest = loadManifest();
|
|
264
|
+
const apps = app ? [app] : Object.keys(manifest.apps);
|
|
265
|
+
const sealed = [];
|
|
266
|
+
for (const a of apps) {
|
|
267
|
+
const entry = manifest.apps[a];
|
|
268
|
+
if (!entry)
|
|
269
|
+
throw new SecretsError(`No secrets found for app: ${a}`);
|
|
270
|
+
if (entry.type === 'env') {
|
|
271
|
+
const runtimePath = join(RUNTIME_DIR, a, '.env');
|
|
272
|
+
if (!existsSync(runtimePath))
|
|
273
|
+
throw new SecretsError(`Runtime file not found: ${runtimePath}`);
|
|
274
|
+
const content = readFileSync(runtimePath, 'utf-8');
|
|
275
|
+
safeSealApp(a, content, entry.sourceFile);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
const runtimeDir = join(RUNTIME_DIR, a, 'secrets');
|
|
279
|
+
if (!existsSync(runtimeDir))
|
|
280
|
+
throw new SecretsError(`Runtime dir not found: ${runtimeDir}`);
|
|
281
|
+
const dirFiles = readdirSync(runtimeDir);
|
|
282
|
+
const secretsMap = {};
|
|
283
|
+
for (const f of dirFiles) {
|
|
284
|
+
secretsMap[f] = readFileSync(join(runtimeDir, f), 'utf-8');
|
|
285
|
+
}
|
|
286
|
+
safeSealDbSecrets(a, secretsMap, entry.sourceFile);
|
|
287
|
+
}
|
|
288
|
+
sealed.push(a);
|
|
289
|
+
}
|
|
290
|
+
return sealed;
|
|
291
|
+
}
|
|
292
|
+
export function rotateKey() {
|
|
293
|
+
const manifest = loadManifest();
|
|
294
|
+
const oldPubkey = getPublicKey();
|
|
295
|
+
const decrypted = {};
|
|
296
|
+
for (const [app, entry] of Object.entries(manifest.apps)) {
|
|
297
|
+
decrypted[app] = ageDecryptFile(join(VAULT_DIR, entry.encryptedFile));
|
|
298
|
+
}
|
|
299
|
+
const backupPath = KEY_PATH + '.old';
|
|
300
|
+
execSync(`cp ${KEY_PATH} ${backupPath}`);
|
|
301
|
+
execSync(`age-keygen -o ${KEY_PATH} 2>/dev/null`);
|
|
302
|
+
chmodSync(KEY_PATH, 0o600);
|
|
303
|
+
const newPubkey = getPublicKey();
|
|
304
|
+
for (const [app, entry] of Object.entries(manifest.apps)) {
|
|
305
|
+
const encrypted = ageEncrypt(decrypted[app]);
|
|
306
|
+
writeFileSync(join(VAULT_DIR, entry.encryptedFile), encrypted);
|
|
307
|
+
entry.lastSealedAt = new Date().toISOString();
|
|
308
|
+
}
|
|
309
|
+
saveManifest(manifest);
|
|
310
|
+
rmSync(backupPath, { force: true });
|
|
311
|
+
return { oldPubkey, newPubkey, appsRotated: Object.keys(manifest.apps) };
|
|
312
|
+
}
|
|
313
|
+
export function getStatus() {
|
|
314
|
+
const init = isInitialized();
|
|
315
|
+
let appCount = 0;
|
|
316
|
+
let totalKeys = 0;
|
|
317
|
+
if (init) {
|
|
318
|
+
const manifest = loadManifest();
|
|
319
|
+
appCount = Object.keys(manifest.apps).length;
|
|
320
|
+
totalKeys = Object.values(manifest.apps).reduce((sum, e) => sum + e.keyCount, 0);
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
initialized: init,
|
|
324
|
+
sealed: isSealed(),
|
|
325
|
+
keyPath: KEY_PATH,
|
|
326
|
+
vaultDir: VAULT_DIR,
|
|
327
|
+
runtimeDir: RUNTIME_DIR,
|
|
328
|
+
appCount,
|
|
329
|
+
totalKeys,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { loadManifest } from './secrets.js';
|
|
4
|
+
import { load } from './registry.js';
|
|
5
|
+
function extractComposeSecrets(composePath, composeFile) {
|
|
6
|
+
const file = composeFile
|
|
7
|
+
? join(composePath, composeFile)
|
|
8
|
+
: join(composePath, 'docker-compose.yml');
|
|
9
|
+
if (!existsSync(file)) {
|
|
10
|
+
const alt = join(composePath, 'compose.yml');
|
|
11
|
+
if (!existsSync(alt))
|
|
12
|
+
return [];
|
|
13
|
+
return parseSecretsFromFile(alt);
|
|
14
|
+
}
|
|
15
|
+
return parseSecretsFromFile(file);
|
|
16
|
+
}
|
|
17
|
+
function parseSecretsFromFile(filePath) {
|
|
18
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
19
|
+
const secrets = [];
|
|
20
|
+
// match top-level secrets: block and extract secret names
|
|
21
|
+
const topLevelMatch = content.match(/^secrets:\s*\n((?:[ \t]+\S.*\n?)*)/m);
|
|
22
|
+
if (!topLevelMatch)
|
|
23
|
+
return [];
|
|
24
|
+
const block = topLevelMatch[1];
|
|
25
|
+
const lines = block.split('\n');
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
// match " secret_name:" at 2-space indent (top-level secret definition)
|
|
28
|
+
const nameMatch = line.match(/^[ \t]{2}(\w[\w-]*):\s*$/);
|
|
29
|
+
if (nameMatch) {
|
|
30
|
+
secrets.push(nameMatch[1]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return secrets;
|
|
34
|
+
}
|
|
35
|
+
function getVaultFiles(app) {
|
|
36
|
+
const manifest = loadManifest();
|
|
37
|
+
const entry = manifest.apps[app];
|
|
38
|
+
if (!entry)
|
|
39
|
+
return [];
|
|
40
|
+
return entry.files ?? [];
|
|
41
|
+
}
|
|
42
|
+
export function validateApp(appName) {
|
|
43
|
+
let composePath;
|
|
44
|
+
let composeFile = null;
|
|
45
|
+
if (appName === 'docker-databases') {
|
|
46
|
+
composePath = '/home/matt/docker-databases';
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const reg = load();
|
|
50
|
+
const app = reg.apps.find(a => a.name === appName);
|
|
51
|
+
if (!app) {
|
|
52
|
+
return { app: appName, ok: false, missing: [], extra: [`App not found in registry`] };
|
|
53
|
+
}
|
|
54
|
+
composePath = app.composePath;
|
|
55
|
+
composeFile = app.composeFile;
|
|
56
|
+
}
|
|
57
|
+
const composeSecrets = extractComposeSecrets(composePath, composeFile);
|
|
58
|
+
if (composeSecrets.length === 0) {
|
|
59
|
+
return { app: appName, ok: true, missing: [], extra: [] };
|
|
60
|
+
}
|
|
61
|
+
const vaultFiles = getVaultFiles(appName);
|
|
62
|
+
// vault files have .txt extension; compose secret names don't — strip for comparison
|
|
63
|
+
const vaultNames = vaultFiles.map(f => f.replace(/\.txt$/, ''));
|
|
64
|
+
const missing = composeSecrets.filter(s => !vaultNames.includes(s));
|
|
65
|
+
const extra = vaultNames.filter(v => !composeSecrets.includes(v));
|
|
66
|
+
return {
|
|
67
|
+
app: appName,
|
|
68
|
+
ok: missing.length === 0,
|
|
69
|
+
missing,
|
|
70
|
+
extra,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function validateAll() {
|
|
74
|
+
const results = [];
|
|
75
|
+
results.push(validateApp('docker-databases'));
|
|
76
|
+
const reg = load();
|
|
77
|
+
for (const app of reg.apps) {
|
|
78
|
+
results.push(validateApp(app.name));
|
|
79
|
+
}
|
|
80
|
+
return results;
|
|
81
|
+
}
|