vibecarbon 0.9.0 → 0.11.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 +110 -663
- package/README.md +4 -4
- package/carbon/.env.example +11 -0
- package/carbon/backup/compose-backup.sh +8 -0
- package/carbon/docker-compose.yml +11 -0
- package/carbon/ha/primary-init.sql +10 -2
- package/carbon/k8s/base/backup/cronjob.yaml +9 -0
- package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
- package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
- package/carbon/k8s/values/supabase.values.yaml +6 -1
- package/carbon/src/client/lib/supabase.ts +41 -0
- package/carbon/src/client/locales/de.json +27 -12
- package/carbon/src/client/locales/en.json +30 -15
- package/carbon/src/client/locales/es.json +27 -12
- package/carbon/src/client/locales/fr.json +27 -12
- package/carbon/src/client/locales/pt.json +27 -12
- package/carbon/src/client/pages/Checkout.tsx +1 -1
- package/carbon/src/server/billing/providers/paddle.ts +21 -2
- package/carbon/src/server/billing/providers/polar.ts +14 -0
- package/carbon/src/server/index.ts +12 -9
- package/carbon/src/server/lib/client-ip.ts +68 -0
- package/carbon/src/server/lib/env.ts +20 -6
- package/carbon/src/server/lib/rate-limiter.ts +11 -65
- package/carbon/src/server/lib/supabase.ts +10 -4
- package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
- package/carbon/src/server/middleware/requirePlan.ts +64 -34
- package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
- package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
- package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
- package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
- package/carbon/src/server/routes/v1/auth.ts +5 -2
- package/carbon/src/server/routes/v1/billing.ts +24 -64
- package/carbon/src/server/routes/v1/index.ts +8 -1
- package/carbon/src/server/routes/v1/newsletter.ts +18 -12
- package/carbon/src/server/routes/v1/theme.ts +36 -15
- package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
- package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
- package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
- package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
- package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
- package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
- package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
- package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
- package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
- package/carbon/tests/unit/server/client-ip.test.ts +76 -0
- package/carbon/tests/unit/server/env-bool.test.ts +63 -0
- package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
- package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
- package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
- package/carbon/volumes/db/wal-archive.sh +36 -0
- package/package.json +2 -2
- package/src/backup.js +0 -2
- package/src/cli.js +16 -0
- package/src/deploy.js +16 -5
- package/src/destroy.js +968 -886
- package/src/failover.js +118 -237
- package/src/lib/command.js +19 -1
- package/src/lib/deploy/compose/ha.js +180 -102
- package/src/lib/deploy/compose/index.js +106 -57
- package/src/lib/deploy/compose/single.js +421 -0
- package/src/lib/deploy/image.js +38 -30
- package/src/lib/deploy/k8s/ha/index.js +397 -150
- package/src/lib/deploy/k8s/k3s.js +411 -279
- package/src/lib/deploy/orchestrator.js +274 -453
- package/src/lib/deploy/remote-build.js +8 -1
- package/src/lib/deploy/replication.js +540 -0
- package/src/lib/deploy/state.js +62 -5
- package/src/lib/deploy/tier-registry.js +30 -0
- package/src/lib/deploy/utils.js +30 -2
- package/src/lib/deploy/wireguard.js +146 -0
- package/src/lib/host-keys.js +120 -5
- package/src/lib/iac/index.js +57 -23
- package/src/lib/licensing/gate.js +59 -0
- package/src/lib/licensing/index.js +1 -1
- package/src/lib/licensing/tiers.js +6 -5
- package/src/lib/prod-confirm.js +65 -0
- package/src/lib/providers/hetzner-s3.js +85 -0
- package/src/lib/retry.js +59 -0
- package/src/lib/scale-plan.js +159 -0
- package/src/lib/ssh.js +35 -35
- package/src/restore.js +102 -14
- package/src/scale.js +105 -133
- package/src/status.js +122 -0
- package/src/upgrade.js +0 -4
- package/carbon/ha/activate-standby.sh +0 -52
- package/carbon/ha/standby-init.sh +0 -74
- package/carbon/src/server/lib/request.ts +0 -34
- package/src/lib/pod-backups.js +0 -53
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical tier taxonomy. Config persists deployMode ('compose' | 'compose-ha'
|
|
3
|
+
* | 'kubernetes') plus an ha flag ('kubernetes-ha' is normalized away at
|
|
4
|
+
* prompt time, see prompts.js). Everything downstream should dispatch on the
|
|
5
|
+
* tier id returned here instead of re-deriving mode+ha combinations.
|
|
6
|
+
*/
|
|
7
|
+
export const TIERS = ['compose', 'compose-ha', 'k8s', 'k8s-ha'];
|
|
8
|
+
|
|
9
|
+
export function resolveTier(envConfig = {}) {
|
|
10
|
+
const { deployMode, ha } = envConfig;
|
|
11
|
+
const haEnabled = typeof ha === 'object' ? !!ha?.enabled : !!ha;
|
|
12
|
+
switch (deployMode) {
|
|
13
|
+
case 'compose':
|
|
14
|
+
return 'compose';
|
|
15
|
+
case 'compose-ha':
|
|
16
|
+
return 'compose-ha';
|
|
17
|
+
case 'kubernetes':
|
|
18
|
+
return haEnabled ? 'k8s-ha' : 'k8s';
|
|
19
|
+
default:
|
|
20
|
+
throw new Error(`Unknown deployMode: ${JSON.stringify(deployMode)}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const isHATier = (tier) => tier === 'compose-ha' || tier === 'k8s-ha';
|
|
25
|
+
export const isComposeTier = (tier) => tier === 'compose' || tier === 'compose-ha';
|
|
26
|
+
export const isK8sTier = (tier) => tier === 'k8s' || tier === 'k8s-ha';
|
|
27
|
+
|
|
28
|
+
export function pulumiStackEnvs(tier, environment) {
|
|
29
|
+
return tier === 'k8s-ha' ? [`${environment}-primary`, `${environment}-standby`] : [environment];
|
|
30
|
+
}
|
package/src/lib/deploy/utils.js
CHANGED
|
@@ -16,9 +16,35 @@ import {
|
|
|
16
16
|
import { tmpdir } from 'node:os';
|
|
17
17
|
import { dirname, join } from 'node:path';
|
|
18
18
|
import { runCommand, runCommandAsync } from '../command.js';
|
|
19
|
+
import { knownHostsPathForKey } from '../host-keys.js';
|
|
19
20
|
import { getProviderClass } from '../providers/index.js';
|
|
20
21
|
import { escapeDotenv } from '../shell.js';
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Build the SSH `-o` options string for compose provisioning, pinned to the
|
|
25
|
+
* per-env known_hosts file derived from the deploy key. NEVER disables host-key
|
|
26
|
+
* verification (no `UserKnownHostsFile=/dev/null` + `StrictHostKeyChecking=no`).
|
|
27
|
+
*
|
|
28
|
+
* `accept-new` pins the host key on first connect (provisioning is trust-on-
|
|
29
|
+
* first-use for ephemeral cloud VMs) then strict-checks on reuse;
|
|
30
|
+
* `GlobalKnownHostsFile=/dev/null` ignores the system-wide file so a recycled
|
|
31
|
+
* Hetzner IP with a stale entry doesn't spuriously reject. Callers that need a
|
|
32
|
+
* stronger pin should `seedKnownHosts()` the IP first (compose/ha.js does).
|
|
33
|
+
*
|
|
34
|
+
* Paths are UNQUOTED on purpose: callers `.split(' ')` this string and spread it
|
|
35
|
+
* into a spawn() argv (no shell), so each token is passed literally — wrapping
|
|
36
|
+
* the path in quotes would make ssh look for a file literally named `"…"` and
|
|
37
|
+
* silently ignore the key/known_hosts. `.vibecarbon/` paths under the project
|
|
38
|
+
* root contain no spaces.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} sshKeyPath
|
|
41
|
+
* @returns {string} space-joined ssh/scp option string
|
|
42
|
+
*/
|
|
43
|
+
export function pinnedSshOptsString(sshKeyPath) {
|
|
44
|
+
const khPath = knownHostsPathForKey(sshKeyPath);
|
|
45
|
+
return `-i ${sshKeyPath} -o UserKnownHostsFile=${khPath} -o GlobalKnownHostsFile=/dev/null -o StrictHostKeyChecking=accept-new -o BatchMode=yes`;
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
// Deployment Defaults
|
|
23
49
|
// Cluster-autoscaler is enabled by default with a static floor of 1 and a
|
|
24
50
|
// bounded ceiling of 3 (CA pool spawns 0..(max-min) workers on top of the
|
|
@@ -173,7 +199,8 @@ export function readReplPassword(cwd = process.cwd()) {
|
|
|
173
199
|
* @returns {Promise<boolean>}
|
|
174
200
|
*/
|
|
175
201
|
export async function waitForSSH(host, sshKeyPath, maxAttempts = 30) {
|
|
176
|
-
|
|
202
|
+
// shell-safety-ignore: pinnedSshOptsString() bakes in BatchMode=yes (validated in its definition)
|
|
203
|
+
const sshOpts = pinnedSshOptsString(sshKeyPath);
|
|
177
204
|
for (let i = 0; i < maxAttempts; i++) {
|
|
178
205
|
try {
|
|
179
206
|
// silent:true → runCommandAsync rejects on failure so the retry loop
|
|
@@ -204,7 +231,8 @@ export async function waitForSSH(host, sshKeyPath, maxAttempts = 30) {
|
|
|
204
231
|
export async function setupServerFiles(host, sshKeyPath, projectName, options = {}) {
|
|
205
232
|
const cwd = process.cwd();
|
|
206
233
|
const remoteDir = `/opt/${projectName}`;
|
|
207
|
-
|
|
234
|
+
// shell-safety-ignore: pinnedSshOptsString() bakes in BatchMode=yes (validated in its definition)
|
|
235
|
+
const sshOpts = pinnedSshOptsString(sshKeyPath);
|
|
208
236
|
|
|
209
237
|
// Create remote directory and required subdirectories
|
|
210
238
|
await runCommandAsync(
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WireGuard config builders for the primary↔standby replication tunnel.
|
|
3
|
+
*
|
|
4
|
+
* Replication used to run over the public internet with app-layer TLS
|
|
5
|
+
* (verify-ca). That transport is being replaced by a point-to-point WireGuard
|
|
6
|
+
* tunnel between the two clusters' gateways: the wire is encrypted by
|
|
7
|
+
* WireGuard itself, so Postgres traffic inside the tunnel runs plaintext
|
|
8
|
+
* (see replication.js — sslmode=disable, plain `host` pg_hba lines).
|
|
9
|
+
*
|
|
10
|
+
* WG_PORT is deliberately NOT 51820 — in k3s that port belongs to
|
|
11
|
+
* flannel's built-in wireguard backend (flannel-wg), so a distinct port is
|
|
12
|
+
* required to avoid colliding with cluster networking.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { sshRun } from '../ssh.js';
|
|
16
|
+
|
|
17
|
+
export const WG_PORT = 51821; // NOT 51820 — flannel-wg owns that in k3s
|
|
18
|
+
export const WG_SUBNET_CIDR = '10.99.0.0/30';
|
|
19
|
+
export const WG_PRIMARY_IP = '10.99.0.1';
|
|
20
|
+
export const WG_STANDBY_IP = '10.99.0.2';
|
|
21
|
+
|
|
22
|
+
// The TCP port the repl-gateway socat relay listens on (both clusters). The
|
|
23
|
+
// standby db pod dials <local-node-private-ip>:REPL_GATEWAY_PORT; the standby
|
|
24
|
+
// gateway relays that into the tunnel to the primary gateway, which relays to
|
|
25
|
+
// the primary's postgres at 127.0.0.1:5433 (the db StatefulSet hostPort).
|
|
26
|
+
// Arbitrary — chosen distinct from postgres 5432/5433 so it never collides on
|
|
27
|
+
// the host.
|
|
28
|
+
export const REPL_GATEWAY_PORT = 15433;
|
|
29
|
+
|
|
30
|
+
// The on-node WireGuard private key. Generated on each supabase node with
|
|
31
|
+
// umask 077 and NEVER read back into this process — only the derived public
|
|
32
|
+
// key ever crosses the wire.
|
|
33
|
+
const WG_KEY_PATH = '/etc/vibecarbon-wg.key';
|
|
34
|
+
|
|
35
|
+
const WG_PUBKEY_RE = /^[A-Za-z0-9+/]{43}=$/; // base64 Curve25519 public key
|
|
36
|
+
const ENDPOINT_RE = /^(\d{1,3}\.){3}\d{1,3}:\d{1,5}$/;
|
|
37
|
+
const ALLOWED_IP_RE = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Argv-safe `wg`/`ip` command sequence bringing up the point-to-point wg0
|
|
41
|
+
* tunnel. Interpolated values are validated (they reach a remote shell), so a
|
|
42
|
+
* malformed peer key/endpoint throws rather than injecting.
|
|
43
|
+
*/
|
|
44
|
+
export function buildWireguardInterfaceConfig({
|
|
45
|
+
selfIp,
|
|
46
|
+
selfPrivKeyPath,
|
|
47
|
+
listenPort,
|
|
48
|
+
peerPubKey,
|
|
49
|
+
peerEndpoint,
|
|
50
|
+
peerAllowedIp,
|
|
51
|
+
keepalive = 25,
|
|
52
|
+
}) {
|
|
53
|
+
if (!WG_PUBKEY_RE.test(peerPubKey || '')) {
|
|
54
|
+
throw new Error(`wireguard: invalid peer pubkey '${peerPubKey}'`);
|
|
55
|
+
}
|
|
56
|
+
if (!ENDPOINT_RE.test(peerEndpoint || '')) {
|
|
57
|
+
throw new Error(`wireguard: invalid peer endpoint '${peerEndpoint}'`);
|
|
58
|
+
}
|
|
59
|
+
if (!ALLOWED_IP_RE.test(peerAllowedIp || '')) {
|
|
60
|
+
throw new Error(`wireguard: invalid peer allowed-ip '${peerAllowedIp}'`);
|
|
61
|
+
}
|
|
62
|
+
return [
|
|
63
|
+
'ip link del wg0 2>/dev/null; true',
|
|
64
|
+
'ip link add wg0 type wireguard',
|
|
65
|
+
`ip addr add ${selfIp}/30 dev wg0`,
|
|
66
|
+
`wg set wg0 private-key ${selfPrivKeyPath} listen-port ${listenPort} ` +
|
|
67
|
+
`peer ${peerPubKey} allowed-ips ${peerAllowedIp} endpoint ${peerEndpoint} ` +
|
|
68
|
+
`persistent-keepalive ${keepalive}`,
|
|
69
|
+
'ip link set wg0 up',
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Generate a WireGuard keypair on each supabase node, cross-distribute the
|
|
75
|
+
* PUBLIC keys, and bring up the point-to-point wg0 tunnel between the two
|
|
76
|
+
* clusters' supabase nodes.
|
|
77
|
+
*
|
|
78
|
+
* Key-exchange security contract:
|
|
79
|
+
* - The private key is generated ON the node (`wg genkey`, umask 077) into
|
|
80
|
+
* WG_KEY_PATH and NEVER read back into this process. `wg set` reads it from
|
|
81
|
+
* the file (`private-key <path>`), so the secret never transits argv, never
|
|
82
|
+
* gets logged, and is never returned.
|
|
83
|
+
* - Only the derived public key (`wg pubkey`) crosses the wire, and each node
|
|
84
|
+
* is configured with the PEER's public key + the peer's `<public-ip>:WG_PORT`
|
|
85
|
+
* endpoint.
|
|
86
|
+
*
|
|
87
|
+
* `primaryIp` / `standbyIp` are the two supabase nodes' PUBLIC IPv4 addresses —
|
|
88
|
+
* they are BOTH the WireGuard endpoints (UDP WG_PORT) AND the SSH targets. All
|
|
89
|
+
* SSH runs go through `sshRun`, which pins host keys and passes
|
|
90
|
+
* `-o BatchMode=yes`.
|
|
91
|
+
*
|
|
92
|
+
* @param {object} o
|
|
93
|
+
* @param {string} o.primaryIp - primary supabase node public IP (SSH + WG endpoint)
|
|
94
|
+
* @param {string} o.standbyIp - standby supabase node public IP (SSH + WG endpoint)
|
|
95
|
+
* @param {string} o.sshKeyPath - path to the shared HA SSH private key
|
|
96
|
+
* @returns {Promise<{primaryPubKey: string, standbyPubKey: string}>}
|
|
97
|
+
*/
|
|
98
|
+
export async function exchangeAndBringUpTunnel({ primaryIp, standbyIp, sshKeyPath }) {
|
|
99
|
+
if (!primaryIp || !standbyIp || !sshKeyPath) {
|
|
100
|
+
throw new Error('exchangeAndBringUpTunnel requires primaryIp, standbyIp, and sshKeyPath');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 1. Ensure wireguard-tools is installed and a private key exists on each
|
|
104
|
+
// node. Idempotent: the install is skipped when `wg` is already present,
|
|
105
|
+
// and the key is generated only if absent (so a re-deploy keeps the same
|
|
106
|
+
// keypair and does not churn the tunnel).
|
|
107
|
+
for (const ip of [primaryIp, standbyIp]) {
|
|
108
|
+
await sshRun(ip, sshKeyPath, [
|
|
109
|
+
'bash',
|
|
110
|
+
'-c',
|
|
111
|
+
'command -v wg >/dev/null 2>&1 || { apt-get update -qq && ' +
|
|
112
|
+
'DEBIAN_FRONTEND=noninteractive apt-get install -y -qq wireguard-tools; }; ' +
|
|
113
|
+
`umask 077; [ -f ${WG_KEY_PATH} ] || wg genkey > ${WG_KEY_PATH}`,
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 2. Read back ONLY the public key from each node (private key stays on-node).
|
|
118
|
+
const primaryPubKey = (
|
|
119
|
+
await sshRun(primaryIp, sshKeyPath, ['bash', '-c', `wg pubkey < ${WG_KEY_PATH}`])
|
|
120
|
+
).trim();
|
|
121
|
+
const standbyPubKey = (
|
|
122
|
+
await sshRun(standbyIp, sshKeyPath, ['bash', '-c', `wg pubkey < ${WG_KEY_PATH}`])
|
|
123
|
+
).trim();
|
|
124
|
+
|
|
125
|
+
// 3. Cross-configure + bring up each node with the PEER's pubkey + endpoint.
|
|
126
|
+
const primaryUp = buildWireguardInterfaceConfig({
|
|
127
|
+
selfIp: WG_PRIMARY_IP,
|
|
128
|
+
selfPrivKeyPath: WG_KEY_PATH,
|
|
129
|
+
listenPort: WG_PORT,
|
|
130
|
+
peerPubKey: standbyPubKey,
|
|
131
|
+
peerEndpoint: `${standbyIp}:${WG_PORT}`,
|
|
132
|
+
peerAllowedIp: `${WG_STANDBY_IP}/32`,
|
|
133
|
+
});
|
|
134
|
+
const standbyUp = buildWireguardInterfaceConfig({
|
|
135
|
+
selfIp: WG_STANDBY_IP,
|
|
136
|
+
selfPrivKeyPath: WG_KEY_PATH,
|
|
137
|
+
listenPort: WG_PORT,
|
|
138
|
+
peerPubKey: primaryPubKey,
|
|
139
|
+
peerEndpoint: `${primaryIp}:${WG_PORT}`,
|
|
140
|
+
peerAllowedIp: `${WG_PRIMARY_IP}/32`,
|
|
141
|
+
});
|
|
142
|
+
await sshRun(primaryIp, sshKeyPath, ['bash', '-c', primaryUp.join(' && ')]);
|
|
143
|
+
await sshRun(standbyIp, sshKeyPath, ['bash', '-c', standbyUp.join(' && ')]);
|
|
144
|
+
|
|
145
|
+
return { primaryPubKey, standbyPubKey };
|
|
146
|
+
}
|
package/src/lib/host-keys.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { dirname, join } from 'node:path';
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { runCommandAsync } from './command.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Absolute path to the per-environment known_hosts file for SSH strict-checking.
|
|
@@ -29,9 +30,123 @@ export function knownHostsPath(env, cwd = process.cwd()) {
|
|
|
29
30
|
*/
|
|
30
31
|
export function pinHostKey(env, hostKeyLines, cwd = process.cwd()) {
|
|
31
32
|
const path = knownHostsPath(env, cwd);
|
|
32
|
-
|
|
33
|
+
writeKnownHosts(path, hostKeyLines);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Low-level writer shared by pinHostKey / seedKnownHosts: overwrite `khPath`
|
|
38
|
+
* with the given lines, enforcing `.vibecarbon/` 0o700 and file 0o600 even
|
|
39
|
+
* when the targets already exist (mkdir/writeFile only apply `mode` on create).
|
|
40
|
+
*
|
|
41
|
+
* @param {string} khPath
|
|
42
|
+
* @param {string[]} hostKeyLines
|
|
43
|
+
*/
|
|
44
|
+
function writeKnownHosts(khPath, hostKeyLines) {
|
|
45
|
+
const dir = dirname(khPath);
|
|
33
46
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
34
47
|
chmodSync(dir, 0o700);
|
|
35
|
-
writeFileSync(
|
|
36
|
-
chmodSync(
|
|
48
|
+
writeFileSync(khPath, `${hostKeyLines.join('\n')}\n`, { mode: 0o600 });
|
|
49
|
+
chmodSync(khPath, 0o600);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Derive the per-environment known_hosts path from an SSH key path.
|
|
54
|
+
*
|
|
55
|
+
* Every SSH/SCP caller in the codebase already threads the deploy key path
|
|
56
|
+
* (`.vibecarbon/deploy_key_<env>` or `.vibecarbon/ssh-<env>`). Placing
|
|
57
|
+
* `known_hosts_<env>` beside it lets every caller pin host keys per-env
|
|
58
|
+
* WITHOUT having to thread a separate `env` argument through helpers that
|
|
59
|
+
* only receive `(ip, sshKeyPath)`. Self-consistent: the same key path always
|
|
60
|
+
* derives the same known_hosts file, so seeding and verification agree.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} sshKeyPath
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function knownHostsPathForKey(sshKeyPath) {
|
|
66
|
+
const dir = dirname(sshKeyPath);
|
|
67
|
+
const env = basename(sshKeyPath)
|
|
68
|
+
.replace(/^deploy_key_/, '')
|
|
69
|
+
.replace(/^ssh-/, '');
|
|
70
|
+
return join(dir, `known_hosts_${env}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Does a known_hosts line's host field reference `ip`? The first
|
|
75
|
+
* whitespace-delimited field is `host[,host2,...]` (we scan un-hashed, so it
|
|
76
|
+
* is the literal IP, not a `|1|…` hash).
|
|
77
|
+
*/
|
|
78
|
+
function hostLineMatchesIp(line, ip) {
|
|
79
|
+
const host = line.split(/\s+/)[0] || '';
|
|
80
|
+
return host.split(',').includes(ip);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function defaultKeyscan(ip) {
|
|
84
|
+
// argv form via runCommandAsync (spawn, no shell). `-T 10` bounds the
|
|
85
|
+
// connect; un-hashed output (no `-H`) so seedKnownHosts can strip a stale
|
|
86
|
+
// line for a recycled IP by exact-match. Best-effort: a freshly-booted VPS
|
|
87
|
+
// may not answer yet, so we ignore errors and let the retry loop poll.
|
|
88
|
+
const out = await runCommandAsync(['ssh-keyscan', '-T', '10', ip], {
|
|
89
|
+
silent: true,
|
|
90
|
+
returnOutput: true,
|
|
91
|
+
timeout: 30_000,
|
|
92
|
+
ignoreError: true,
|
|
93
|
+
});
|
|
94
|
+
return typeof out === 'string' ? out : '';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Seed (or re-seed) the per-env known_hosts file at `khPath` with the target
|
|
99
|
+
* server's real host keys, captured via `ssh-keyscan`. This is the "trusted
|
|
100
|
+
* source" for the pin, run once per env on fresh (re)provision.
|
|
101
|
+
*
|
|
102
|
+
* SECURITY — chosen semantics (documented so the trade-off is auditable):
|
|
103
|
+
* - Merge, don't clobber: existing lines for OTHER hosts are preserved (HA
|
|
104
|
+
* keeps primary + standby in one per-env file), but any stale line for
|
|
105
|
+
* THIS ip is dropped before the fresh scan is appended. That re-pin is why
|
|
106
|
+
* a Hetzner-recycled IP (destroy → redeploy, same IP, new host key) does
|
|
107
|
+
* NOT spuriously hard-fail: provisioning replaces the stale pin.
|
|
108
|
+
* - Only provisioning re-seeds. Every non-provision command (backup, restore,
|
|
109
|
+
* scale, status, warm deploy) strict-checks against the pinned file and
|
|
110
|
+
* NEVER re-seeds, so a MITM against an already-established env fails.
|
|
111
|
+
* - ssh-keyscan itself is trust-on-first-provision (it accepts whatever key
|
|
112
|
+
* the freshly-created VPS presents). This is the pragmatic trust anchor for
|
|
113
|
+
* ephemeral cloud VMs with no out-of-band key channel.
|
|
114
|
+
*
|
|
115
|
+
* Returns true if a scan landed and the file was written; false if the scan
|
|
116
|
+
* came back empty (caller's SSH opts still `accept-new`-TOFU on first connect).
|
|
117
|
+
*
|
|
118
|
+
* @param {string} khPath - per-env known_hosts path (see knownHostsPathForKey)
|
|
119
|
+
* @param {string} ip
|
|
120
|
+
* @param {object} [opts]
|
|
121
|
+
* @param {number} [opts.attempts=5]
|
|
122
|
+
* @param {number} [opts.delayMs=2000]
|
|
123
|
+
* @param {(ip: string) => Promise<string>} [opts.keyscan] - injectable for tests
|
|
124
|
+
* @param {(ms: number) => Promise<void>} [opts.sleep]
|
|
125
|
+
* @returns {Promise<boolean>}
|
|
126
|
+
*/
|
|
127
|
+
export async function seedKnownHosts(khPath, ip, opts = {}) {
|
|
128
|
+
const {
|
|
129
|
+
attempts = 5,
|
|
130
|
+
delayMs = 2000,
|
|
131
|
+
keyscan = defaultKeyscan,
|
|
132
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
133
|
+
} = opts;
|
|
134
|
+
|
|
135
|
+
let scanned = '';
|
|
136
|
+
for (let i = 0; i < attempts; i++) {
|
|
137
|
+
scanned = (await keyscan(ip)).trim();
|
|
138
|
+
if (scanned) break;
|
|
139
|
+
if (i < attempts - 1) await sleep(delayMs);
|
|
140
|
+
}
|
|
141
|
+
if (!scanned) return false;
|
|
142
|
+
|
|
143
|
+
let kept = [];
|
|
144
|
+
if (existsSync(khPath)) {
|
|
145
|
+
kept = readFileSync(khPath, 'utf-8')
|
|
146
|
+
.split('\n')
|
|
147
|
+
.filter((line) => line.trim() && !hostLineMatchesIp(line, ip));
|
|
148
|
+
}
|
|
149
|
+
const merged = [...kept, ...scanned.split('\n').filter((l) => l.trim())];
|
|
150
|
+
writeKnownHosts(khPath, merged);
|
|
151
|
+
return true;
|
|
37
152
|
}
|
package/src/lib/iac/index.js
CHANGED
|
@@ -39,15 +39,23 @@ mkdirSync(LOCAL_STATE_DIR, { recursive: true });
|
|
|
39
39
|
*
|
|
40
40
|
* Hetzner S3 uses path-style URLs via the Hetzner-provided endpoint, so we
|
|
41
41
|
* pass both `endpoint` and `s3ForcePathStyle=true` in the query string.
|
|
42
|
+
*
|
|
43
|
+
* State lives in a DEDICATED bucket (`s3Config.stateBucket`), separate from
|
|
44
|
+
* the app storage bucket, so `destroy` deleting the app bucket can never yank
|
|
45
|
+
* the Pulumi backend out from under an in-flight destroy. The `?? bucket`
|
|
46
|
+
* fallback preserves behavior for any un-migrated path (configs persisted
|
|
47
|
+
* before the dedicated-state-bucket change, whose state still lives in the app
|
|
48
|
+
* bucket) — the deploy path always sets `stateBucket`.
|
|
42
49
|
*/
|
|
43
50
|
export function resolveBackendUrl(s3Config) {
|
|
44
51
|
if (process.env.PULUMI_BACKEND_URL) {
|
|
45
52
|
return process.env.PULUMI_BACKEND_URL;
|
|
46
53
|
}
|
|
47
54
|
if (s3Config?.bucket && s3Config?.endpoint) {
|
|
55
|
+
const stateBucket = s3Config.stateBucket ?? s3Config.bucket;
|
|
48
56
|
const endpointHost = s3Config.endpoint.replace(/^https?:\/\//, '');
|
|
49
57
|
const region = s3Config.region || 'nbg1';
|
|
50
|
-
return `s3://${
|
|
58
|
+
return `s3://${stateBucket}?endpoint=${endpointHost}®ion=${region}&s3ForcePathStyle=true`;
|
|
51
59
|
}
|
|
52
60
|
return `file://${LOCAL_STATE_DIR}`;
|
|
53
61
|
}
|
|
@@ -80,20 +88,30 @@ export async function getOrCreateStack(stackName, program, options = {}) {
|
|
|
80
88
|
const backendUrl = resolveBackendUrl(options.s3Config);
|
|
81
89
|
const envVars = buildEnv(options);
|
|
82
90
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
91
|
+
// createOrSelectStack does a `pulumi stack select` that reads
|
|
92
|
+
// `.pulumi/meta.yaml` from the S3 backend. On a freshly-created dedicated
|
|
93
|
+
// state bucket, Hetzner Object Storage frequently throttles this first read
|
|
94
|
+
// with `SlowDown` / an Unknown blob error, which would kill the deploy before
|
|
95
|
+
// any resource is touched. Wrap it in the same throttle-retry the up/refresh
|
|
96
|
+
// ops use so the initial select rides out the fresh-bucket throttle.
|
|
97
|
+
const stack = await withStateBackendRetry(
|
|
98
|
+
() =>
|
|
99
|
+
LocalWorkspace.createOrSelectStack(
|
|
100
|
+
{
|
|
101
|
+
stackName,
|
|
102
|
+
projectName: PULUMI_PROJECT,
|
|
103
|
+
program,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
projectSettings: {
|
|
107
|
+
name: PULUMI_PROJECT,
|
|
108
|
+
runtime: 'nodejs',
|
|
109
|
+
backend: { url: backendUrl },
|
|
110
|
+
},
|
|
111
|
+
envVars,
|
|
112
|
+
},
|
|
113
|
+
),
|
|
114
|
+
`stack-select ${stackName}`,
|
|
97
115
|
);
|
|
98
116
|
|
|
99
117
|
return stack;
|
|
@@ -105,10 +123,12 @@ export async function getOrCreateStack(stackName, program, options = {}) {
|
|
|
105
123
|
* fails with `could not list bucket: blob (code=NotFound): NoSuchKey`
|
|
106
124
|
* because the backend hasn't laid down its `.pulumi/meta.yaml` yet. When
|
|
107
125
|
* `deployK8sHA` launches primary + standby `deployK3s` in parallel against
|
|
108
|
-
* the SAME bucket, both threads race the bucket-init and
|
|
109
|
-
* NoSuchKey error, killing the deploy in <20s. Calling this once
|
|
126
|
+
* the SAME (dedicated) state bucket, both threads race the bucket-init and
|
|
127
|
+
* both see the NoSuchKey error, killing the deploy in <20s. Calling this once
|
|
110
128
|
* sequentially before the parallel block writes the backend metadata so
|
|
111
|
-
* subsequent stack ops succeed.
|
|
129
|
+
* subsequent stack ops succeed. (Both HA stacks share ONE state bucket, so
|
|
130
|
+
* this parallel-init race persists even with the dedicated bucket — hence we
|
|
131
|
+
* keep the pre-init.)
|
|
112
132
|
*
|
|
113
133
|
* Idempotent: if the bucket is already initialized, the no-op stack is
|
|
114
134
|
* created and removed cleanly. If it fails (e.g., bucket access issue),
|
|
@@ -142,8 +162,12 @@ export async function initBackend(options = {}) {
|
|
|
142
162
|
// once). It is also a transient any single deploy can hit. These ops are all
|
|
143
163
|
// idempotent (Pulumi reconciles to desired state), so retrying on a throttle is
|
|
144
164
|
// safe. Non-throttle errors propagate immediately.
|
|
165
|
+
//
|
|
166
|
+
// "stack is currently locked" is included: a SlowDown-interrupted `up` can leave
|
|
167
|
+
// its own lock behind, so a lock error is retryable — the up wrapper cancels the
|
|
168
|
+
// lock before each attempt (safe: e2e/deploys run one operation per stack).
|
|
145
169
|
export const STATE_BACKEND_THROTTLE_PATTERN =
|
|
146
|
-
/SlowDown|ServiceUnavailable|RequestLimitExceeded|throttl|too many requests|\b503\b/i;
|
|
170
|
+
/SlowDown|ServiceUnavailable|RequestLimitExceeded|throttl|too many requests|\b503\b|currently locked|lock\(s\)/i;
|
|
147
171
|
|
|
148
172
|
export async function withStateBackendRetry(fn, desc, options = {}) {
|
|
149
173
|
const attempts = options.attempts ?? 5;
|
|
@@ -222,10 +246,20 @@ export async function upStack(stackName, program, options = {}) {
|
|
|
222
246
|
}
|
|
223
247
|
}
|
|
224
248
|
|
|
225
|
-
const result = await withStateBackendRetry(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
249
|
+
const result = await withStateBackendRetry(async () => {
|
|
250
|
+
// A `up` that failed on an S3 `SlowDown` (common on a freshly-created
|
|
251
|
+
// dedicated state bucket, especially under the parallel HA primary+standby
|
|
252
|
+
// deploys) may have already acquired the stack lock before erroring. The
|
|
253
|
+
// retry would then fail with "stack is currently locked". Clear the lock
|
|
254
|
+
// before EACH attempt — cancel() is idempotent and per-stack, so it's safe
|
|
255
|
+
// under the parallel HA deploys (primary/standby are separate stacks).
|
|
256
|
+
try {
|
|
257
|
+
await stack.cancel();
|
|
258
|
+
} catch {
|
|
259
|
+
/* no lock — ignore */
|
|
260
|
+
}
|
|
261
|
+
return stack.up({ onOutput: options.onOutput, color: 'never' });
|
|
262
|
+
}, 'up');
|
|
229
263
|
// Flatten outputs: { foo: { value: 'bar', secret: false } } → { foo: 'bar' }
|
|
230
264
|
const outputs = {};
|
|
231
265
|
for (const [key, { value }] of Object.entries(result.outputs || {})) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central license gate — the single pre-dispatch chokepoint for paid commands.
|
|
3
|
+
*
|
|
4
|
+
* cli.js consults COMMAND_GATES before routing to a command module, so a
|
|
5
|
+
* paid command can never ship unguarded: every command registered in
|
|
6
|
+
* KNOWN_COMMANDS must have an explicit classification here (enforced by
|
|
7
|
+
* tests/unit/licensing/command-gates.test.ts).
|
|
8
|
+
*
|
|
9
|
+
* Classifications:
|
|
10
|
+
* 'paid' — requires an active Diamond/Fullerene license. Gated in
|
|
11
|
+
* cli.js after the project guard (assertInProjectDir is the
|
|
12
|
+
* documented first action for project-scoped commands — the
|
|
13
|
+
* canonical "not in a project" refusal must win over the
|
|
14
|
+
* license upsell; see tests/integration/cli/_global/).
|
|
15
|
+
* 'free' — never gated. destroy is deliberately free: teardown is
|
|
16
|
+
* never held hostage to a license.
|
|
17
|
+
* 'internal' — the command gates a sub-flow itself. configure only gates
|
|
18
|
+
* `configure cicd`, which is also reachable via interactive
|
|
19
|
+
* prompt selection, so the gate must live in-flow.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const COMMAND_GATES = {
|
|
23
|
+
create: 'free',
|
|
24
|
+
add: 'free',
|
|
25
|
+
remove: 'free',
|
|
26
|
+
up: 'free',
|
|
27
|
+
down: 'free',
|
|
28
|
+
reset: 'free',
|
|
29
|
+
deploy: 'paid',
|
|
30
|
+
destroy: 'free',
|
|
31
|
+
status: 'free',
|
|
32
|
+
backup: 'paid',
|
|
33
|
+
restore: 'paid',
|
|
34
|
+
failover: 'paid',
|
|
35
|
+
scale: 'paid',
|
|
36
|
+
upgrade: 'paid',
|
|
37
|
+
configure: 'internal',
|
|
38
|
+
activate: 'free',
|
|
39
|
+
deactivate: 'free',
|
|
40
|
+
shell: 'free',
|
|
41
|
+
diagnose: 'free',
|
|
42
|
+
console: 'free',
|
|
43
|
+
access: 'free',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether this invocation must hold a paid license.
|
|
48
|
+
* Help/version invocations are always free — every command handles -h/-v
|
|
49
|
+
* before doing real work, and the upsell must never block reading docs.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} command - The subcommand name
|
|
52
|
+
* @param {string[]} args - The subcommand's argv (without the command itself)
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function shouldGate(command, args = []) {
|
|
56
|
+
if (COMMAND_GATES[command] !== 'paid') return false;
|
|
57
|
+
if (args.includes('-h') || args.includes('-v')) return false;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
@@ -186,7 +186,7 @@ export function requireLicense(commandName) {
|
|
|
186
186
|
` ${c.bold('Vibecarbon Diamond')} — ${c.success('$149 lifetime')} ${c.dim('— deploy to production, indie & startup use')}`,
|
|
187
187
|
);
|
|
188
188
|
console.log(
|
|
189
|
-
` ${c.bold('Vibecarbon Fullerene')} — ${c.success('$499 lifetime')} ${c.dim('— deploy for clients & agencies,
|
|
189
|
+
` ${c.bold('Vibecarbon Fullerene')} — ${c.success('$499 lifetime')} ${c.dim('— deploy for clients & agencies, FSL exception')}`,
|
|
190
190
|
);
|
|
191
191
|
console.log('');
|
|
192
192
|
console.log(` ${c.dim('Purchase:')} ${c.info('https://vibecarbon.com/#pricing')}`);
|
|
@@ -13,7 +13,7 @@ export const TIERS = {
|
|
|
13
13
|
features: ['local-dev', 'all-addons'],
|
|
14
14
|
providers: [],
|
|
15
15
|
maxServers: 0,
|
|
16
|
-
license: 'MIT',
|
|
16
|
+
license: 'FSL-1.1-MIT',
|
|
17
17
|
deployFlags: [],
|
|
18
18
|
// Pricing
|
|
19
19
|
price: 0,
|
|
@@ -25,6 +25,7 @@ export const TIERS = {
|
|
|
25
25
|
'Local development',
|
|
26
26
|
'Full Vibecarbon stack',
|
|
27
27
|
'All add-ons (observability, n8n, metabase, redis, CI/CD)',
|
|
28
|
+
'Fair Source — audit, fork, use commercially',
|
|
28
29
|
'Community support',
|
|
29
30
|
],
|
|
30
31
|
},
|
|
@@ -44,7 +45,7 @@ export const TIERS = {
|
|
|
44
45
|
],
|
|
45
46
|
providers: ['hetzner'],
|
|
46
47
|
maxServers: Infinity,
|
|
47
|
-
license: '
|
|
48
|
+
license: 'FSL-1.1-MIT',
|
|
48
49
|
deployFlags: ['--ha', '--k8s'],
|
|
49
50
|
// Pricing
|
|
50
51
|
price: 149,
|
|
@@ -77,11 +78,11 @@ export const TIERS = {
|
|
|
77
78
|
'failover',
|
|
78
79
|
'advanced-monitoring',
|
|
79
80
|
'all-addons',
|
|
80
|
-
'
|
|
81
|
+
'fsl-exception',
|
|
81
82
|
],
|
|
82
83
|
providers: ['hetzner'],
|
|
83
84
|
maxServers: Infinity,
|
|
84
|
-
license: '
|
|
85
|
+
license: 'FSL-1.1-MIT + FSL Exception',
|
|
85
86
|
deployFlags: ['--ha', '--k8s'],
|
|
86
87
|
// Pricing
|
|
87
88
|
price: 499,
|
|
@@ -90,7 +91,7 @@ export const TIERS = {
|
|
|
90
91
|
badge: 'Lifetime Access',
|
|
91
92
|
// Marketing
|
|
92
93
|
marketingFeatures: [
|
|
93
|
-
'
|
|
94
|
+
'FSL exception: embed, white-label, or resell Vibecarbon-powered services',
|
|
94
95
|
'Deploy for clients & organizations',
|
|
95
96
|
'All deployment modes (Compose, Compose HA, Kubernetes)',
|
|
96
97
|
'Full Vibecarbon stack',
|