vibecarbon 0.11.0 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibecarbon",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Create and manage production-ready Vibecarbon applications",
5
5
  "type": "module",
6
6
  "bin": "./src/cli.js",
@@ -28,8 +28,10 @@ import { useDnsChallenge } from '../acme.js';
28
28
  import {
29
29
  assertReplicationStreamingOrDegraded,
30
30
  buildPrimaryConninfo,
31
+ buildReplicationFirewallRules,
31
32
  buildReplicationHbaLine,
32
33
  buildStagedBasebackupScript,
34
+ REPL_PORT,
33
35
  verifyStreaming,
34
36
  } from '../replication.js';
35
37
  import {
@@ -38,7 +40,13 @@ import {
38
40
  pinnedSshOptsString,
39
41
  readReplPassword,
40
42
  } from '../utils.js';
41
- import { WG_SUBNET_CIDR } from '../wireguard.js';
43
+ import {
44
+ exchangeAndBringUpTunnel,
45
+ REPL_GATEWAY_PORT,
46
+ WG_PRIMARY_IP,
47
+ WG_STANDBY_IP,
48
+ WG_SUBNET_CIDR,
49
+ } from '../wireguard.js';
42
50
  import {
43
51
  createAdminUser,
44
52
  dockerLoginOnServer,
@@ -221,17 +229,83 @@ async function deleteFirewallByName(name, hetznerHeaders, deadlineMs = 30_000) {
221
229
  // deployMode === 'compose-ha' (skipped for single-region compose, which has no
222
230
  // standby). configureStandbyReplication is exported from this file for that.
223
231
 
232
+ /**
233
+ * Build the compose replication overlay (`docker-compose.replication.yml`) for
234
+ * ONE node, with that node's own WireGuard tunnel IP baked into the relay.
235
+ *
236
+ * Two pieces:
237
+ * 1. `db` publishes the raw replication port (REPL_PORT=5433 → container 5432)
238
+ * so the local socat relay has a plaintext postgres to forward to
239
+ * (supavisor owns 5432 and can't speak the replication protocol).
240
+ * 2. `repl-gateway` — a socat relay on the HOST network namespace (so it sees
241
+ * the host's `wg0`, brought up by exchangeAndBringUpTunnel) that binds this
242
+ * node's OWN tunnel IP (`selfWgIp:REPL_GATEWAY_PORT`) and forwards to the
243
+ * local db (`127.0.0.1:REPL_PORT`). Binding the tunnel IP (not 0.0.0.0)
244
+ * means the relay is reachable ONLY over wg0 — never on the public IP — so
245
+ * it needs no firewall rule of its own. NO `NET_ADMIN`/in-container
246
+ * WireGuard: the tunnel lives on the host, the container is a pure relay.
247
+ * `restart: unless-stopped` so a transient socat exit self-heals.
248
+ *
249
+ * Each node self-exposes its LOCAL db at `<self-tunnel-ip>:REPL_GATEWAY_PORT`,
250
+ * which makes the transport symmetric for failover: whichever node is currently
251
+ * standby dials the current primary's tunnel IP over wg0 (docker bridge → host
252
+ * wg0), and the promoted node already exposes its db on its own tunnel IP.
253
+ *
254
+ * @param {string} selfWgIp - this node's WireGuard tunnel IP (WG_PRIMARY_IP on
255
+ * the primary, WG_STANDBY_IP on the standby)
256
+ * @returns {string} the YAML overlay content
257
+ */
258
+ export function buildReplicationOverlay(selfWgIp) {
259
+ return `version: "3.8"
260
+ services:
261
+ db:
262
+ ports:
263
+ - "${REPL_PORT}:5432"
264
+ repl-gateway:
265
+ image: alpine/socat
266
+ network_mode: host
267
+ restart: unless-stopped
268
+ command:
269
+ - "TCP-LISTEN:${REPL_GATEWAY_PORT},bind=${selfWgIp},fork,reuseaddr"
270
+ - "TCP:127.0.0.1:${REPL_PORT}"
271
+ `;
272
+ }
273
+
274
+ // The compose flags that include the replication overlay so `up`/`restart`
275
+ // against the repl-gateway service resolves it.
276
+ const REPL_COMPOSE_FLAGS =
277
+ '-f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.replication.yml';
278
+
224
279
  /**
225
280
  * Configure the primary PostgreSQL server for streaming replication.
226
281
  *
227
- * 1. Create a dedicated replication user
228
- * 2. Allow replication from the WireGuard tunnel subnet in pg_hba.conf (plain
282
+ * 1. Bring up the point-to-point WireGuard tunnel (host wg0, UDP 51821) between
283
+ * the two nodes and start the primary's `repl-gateway` socat relay
284
+ * 2. Create a dedicated replication user
285
+ * 3. Allow replication from the WireGuard tunnel subnet in pg_hba.conf (plain
229
286
  * `host` — WireGuard encrypts the wire, so Postgres doesn't also need TLS)
230
- * 3. Set wal_level=replica and max_wal_senders
231
- * 4. Restart PostgreSQL to apply changes
287
+ * 4. Set wal_level=replica and max_wal_senders
288
+ * 5. Restart PostgreSQL to apply changes
232
289
  */
233
- async function configurePrimaryReplication(primaryIp, sshKeyPath, projectName) {
290
+ export async function configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName) {
234
291
  const remoteDir = `/opt/${projectName}`;
292
+
293
+ // Establish the WireGuard replication transport. Keys are generated on-node
294
+ // (private key never leaves the node); only the derived public keys transit
295
+ // the orchestrator. Endpoints/SSH targets are the two VPS PUBLIC IPs. This
296
+ // must run BEFORE starting the socat relay, since the relay binds the tunnel
297
+ // IP (10.99.0.x) which only exists once `wg0` is up.
298
+ await exchangeAndBringUpTunnel({ primaryIp, standbyIp, sshKeyPath });
299
+
300
+ // Start the primary's repl-gateway (socat, host netns). The overlay file was
301
+ // written to the node earlier in the deploy flow; `up -d repl-gateway` binds
302
+ // WG_PRIMARY_IP:REPL_GATEWAY_PORT → 127.0.0.1:REPL_PORT now that wg0 exists.
303
+ await sshRun(
304
+ primaryIp,
305
+ sshKeyPath,
306
+ `cd ${remoteDir} && docker compose ${REPL_COMPOSE_FLAGS} up -d repl-gateway 2>&1`,
307
+ { timeout: 60_000 },
308
+ );
235
309
  // replPassword is generated at create time from crypto.randomBytes and is
236
310
  // restricted to base64url characters — no shell escaping needed.
237
311
  // Read from process.env first (CI), then .env.local (the `vibecarbon create`
@@ -315,14 +389,10 @@ END $$;
315
389
  { timeout: 30_000 },
316
390
  );
317
391
 
318
- // TODO(wireguard-transport): tunnel bring-up Task 6.
319
- //
320
- // This used to enable server-side TLS (verify-ca) here: copy the per-deploy
321
- // CA/cert/key from the bind-mounted Secret into PGDATA and ALTER SYSTEM SET
322
- // ssl = 'on' + ssl_cert_file/ssl_key_file/ssl_ca_file. That's public-IP-era
323
- // plumbing for the TLS transport being replaced — WireGuard encrypts the
324
- // wire instead, so there's no cert material to install here. Task 6 wires
325
- // the actual wg0 tunnel bring-up for compose-ha.
392
+ // No app-layer TLS. Replication runs over the WireGuard tunnel (brought up
393
+ // above), which encrypts the wire, so Postgres itself speaks plaintext — the
394
+ // retired verify-ca transport's cert install + `ALTER SYSTEM SET ssl='on'`
395
+ // are gone (public-IP-era plumbing).
326
396
 
327
397
  // Restart PostgreSQL to apply WAL changes
328
398
  await sshRun(primaryIp, sshKeyPath, `cd ${remoteDir} && docker compose restart db`, {
@@ -394,6 +464,19 @@ END $$;`;
394
464
  { timeout: 30_000 },
395
465
  );
396
466
 
467
+ // Start the standby's repl-gateway (socat, host netns). The tunnel (host wg0)
468
+ // is already up — configurePrimaryReplication brought it up on both nodes at
469
+ // deploy time, and it persists across the restore/re-seed re-entry. Binding
470
+ // WG_STANDBY_IP:REPL_GATEWAY_PORT self-exposes the standby's LOCAL db over the
471
+ // tunnel so the transport is symmetric after a failover (the promoted standby
472
+ // is then the primary, and the old primary streams from WG_STANDBY_IP).
473
+ await sshRun(
474
+ standbyIp,
475
+ sshKeyPath,
476
+ `cd ${remoteDir} && docker compose ${REPL_COMPOSE_FLAGS} up -d repl-gateway 2>&1`,
477
+ { timeout: 60_000 },
478
+ );
479
+
397
480
  // Stop the standby database
398
481
  await sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose stop db`, {
399
482
  timeout: 60_000,
@@ -406,9 +489,17 @@ END $$;`;
406
489
  // atomically swaps in only after verifying PG_VERSION, so a failed/partial
407
490
  // basebackup never leaves PGDATA half-formed (the `initdb: directory exists
408
491
  // but is not empty` crash-loop). probeFirst aborts cleanly (exit 0, PGDATA
409
- // untouched) when the primary's REPL_PORT is unreachable. Plaintext (no
410
- // PGSSLMODE/PGSSLROOTCERT) — see TODO(wireguard-transport) above; the
411
- // connection is unencrypted until Task 6 lands the tunnel.
492
+ // untouched) when the primary's gateway is unreachable. Plaintext (no
493
+ // PGSSLMODE/PGSSLROOTCERT) — WireGuard is the encryption layer.
494
+ //
495
+ // The connection targets the PRIMARY's WireGuard tunnel IP (WG_PRIMARY_IP) on
496
+ // the repl-gateway relay port (REPL_GATEWAY_PORT), NOT the primary's public
497
+ // IP: the standby db container reaches 10.99.0.1:15433 via the host's wg0
498
+ // (docker bridge → host route → wg0), where the primary's socat relay
499
+ // forwards to the primary db (127.0.0.1:REPL_PORT). WG_PRIMARY_IP is fixed to
500
+ // whichever node is currently primary (exchangeAndBringUpTunnel assigns it),
501
+ // so this stays correct across a role swap once failover re-establishes the
502
+ // tunnel with swapped endpoints.
412
503
  //
413
504
  // After the swap we pin primary_conninfo explicitly (last entry in
414
505
  // postgresql.auto.conf wins): pg_basebackup -R records a conninfo from the
@@ -418,12 +509,13 @@ END $$;`;
418
509
  // left untouched.
419
510
  const basebackupScript = `${buildStagedBasebackupScript({
420
511
  replPassword,
421
- primaryHost: primaryIp,
512
+ primaryHost: WG_PRIMARY_IP,
513
+ primaryPort: String(REPL_GATEWAY_PORT),
422
514
  probeFirst: true,
423
515
  label: 'compose-ha-repl',
424
516
  })}
425
517
  cat >> /var/lib/postgresql/data/postgresql.auto.conf <<'REPL_CONNINFO_EOF'
426
- primary_conninfo = '${buildPrimaryConninfo({ primaryHost: primaryIp, replPassword })}'
518
+ primary_conninfo = '${buildPrimaryConninfo({ primaryHost: WG_PRIMARY_IP, port: REPL_GATEWAY_PORT, replPassword })}'
427
519
  REPL_CONNINFO_EOF
428
520
  chown postgres:postgres /var/lib/postgresql/data/postgresql.auto.conf
429
521
  `;
@@ -549,44 +641,41 @@ chown postgres:postgres /var/lib/postgresql/data/postgresql.auto.conf
549
641
  // ============================================================================
550
642
 
551
643
  /**
552
- * Open PostgreSQL port (5432) between the two servers via UFW.
553
- * Only allows traffic from the peer's IP — not from the public internet.
554
- */
555
- /**
556
- * Open the dedicated replication port between HA peers.
644
+ * Open the WireGuard tunnel port (UDP 51821) from the HA peer in UFW.
557
645
  *
558
- * Port 5433 maps directly to the db container's PostgreSQL (bypassing supavisor
559
- * on port 5432). This is required because supavisor doesn't support PostgreSQL
560
- * replication protocol, and pg_basebackup / streaming replication need a raw
561
- * PostgreSQL connection.
646
+ * Replication no longer traverses a public TCP port it runs over a
647
+ * point-to-point WireGuard tunnel between the two nodes' public IPs. The only
648
+ * inbound the peer needs is the WG handshake/data on UDP 51821 (51820 is
649
+ * flannel-wg's in k3s; compose uses 51821 for parity). Scoped to the peer's IP,
650
+ * never the public internet. The raw replication port (5433) is no longer
651
+ * exposed to the peer at the host firewall — the socat relay binds the tunnel
652
+ * IP and is reachable only over wg0.
562
653
  */
563
- async function openReplicationPort(ip, peerIp, sshKeyPath) {
564
- await sshRunAsync(ip, sshKeyPath, `ufw allow from ${peerIp} to any port 5433 proto tcp`, {
654
+ async function openWireguardPortUfw(ip, peerIp, sshKeyPath) {
655
+ await sshRunAsync(ip, sshKeyPath, `ufw allow from ${peerIp} to any port 51821 proto udp`, {
565
656
  timeout: 15_000,
566
657
  });
567
658
  }
568
659
 
569
660
  /**
570
- * Add an `in tcp 5433` rule scoped to the peer's IP on a server's Hetzner Cloud
571
- * firewall. UFW alone isn't enough — Hetzner Cloud Firewall sits in front of
572
- * the host and blocks SYN before it ever reaches UFW. Without this, every
573
- * pg_basebackup attempt from the peer hits a TCP `Connection timed out`,
574
- * configureStandbyReplication's bash script (with set -e + bash) fails on
575
- * pg_basebackup, the catch path throws "pg_basebackup failed", but historically
576
- * the script ran under dash (no `set -e -o pipefail` support) and silently
577
- * proceeded — so the warning surfaced two stages later as "did not enter
578
- * recovery mode within 60s" with the standby actually running its first-init
579
- * primary-mode state. RCA: compose-ha 2026-05-01 fanout11.
661
+ * Open the WireGuard tunnel port (UDP 51821) scoped to the peer's IP on a
662
+ * server's Hetzner Cloud firewall. UFW alone isn't enough — the Cloud Firewall
663
+ * sits in front of the host and drops the packet before it ever reaches UFW.
664
+ * Without this, the WG handshake from the peer never lands, the tunnel never
665
+ * comes up, and pg_basebackup over the relay times out.
580
666
  *
581
667
  * The Pulumi-managed firewall (src/lib/iac/programs/hetzner-compose.js) only
582
- * declares 22/80/443 because the standby's IP isn't known at primary stack-up
583
- * time. Patch in 5433 here, after both stacks are up.
668
+ * declares 22/80/443 because the peer's IP isn't known at stack-up time. Patch
669
+ * in the UDP 51821 rule here, after both stacks are up. Uses the shared
670
+ * buildReplicationFirewallRules builder so compose-ha and k8s-ha compute the
671
+ * exact same rule set (peer-scoped udp/51821 + scrubs any stale tcp
672
+ * 5432/5433/30432 rules from the retired public-IP TLS transport).
584
673
  *
585
- * Idempotent: skips if a matching rule already exists. Non-fatal on error —
586
- * the deploy continues with a warning so the operator can iterate the rest of
587
- * the scenario; HA replication just won't actually work until 5433 is open.
674
+ * Idempotent: buildReplicationFirewallRules returns null when the rule already
675
+ * exists and no stale rules remain. Non-fatal on error the deploy continues
676
+ * with a warning; HA replication just won't work until 51821 is open.
588
677
  */
589
- async function openReplicationPortHetznerFirewall(serverName, peerIp, hetznerToken) {
678
+ async function openWireguardPortHetznerFirewall(serverName, peerIp, hetznerToken) {
590
679
  if (!hetznerToken) return;
591
680
  const firewallName = `${serverName}-firewall`;
592
681
  const headers = {
@@ -600,22 +689,8 @@ async function openReplicationPortHetznerFirewall(serverName, peerIp, hetznerTok
600
689
  const fwListData = await fwListRes.json();
601
690
  const firewall = fwListData.firewalls?.find((fw) => fw.name === firewallName);
602
691
  if (!firewall) return;
603
- const existingRules = firewall.rules || [];
604
- const peerCidr = `${peerIp}/32`;
605
- const hasReplRule = existingRules.some(
606
- (r) => r.port === '5433' && r.source_ips?.includes(peerCidr),
607
- );
608
- if (hasReplRule) return;
609
- const updatedRules = [
610
- ...existingRules,
611
- {
612
- direction: 'in',
613
- protocol: 'tcp',
614
- port: '5433',
615
- source_ips: [peerCidr],
616
- description: 'PostgreSQL replication from HA peer',
617
- },
618
- ];
692
+ const updatedRules = buildReplicationFirewallRules(firewall.rules || [], peerIp);
693
+ if (!updatedRules) return; // already correct
619
694
  await fetchWithRetry(`https://api.hetzner.cloud/v1/firewalls/${firewall.id}/actions/set_rules`, {
620
695
  method: 'POST',
621
696
  headers,
@@ -958,27 +1033,28 @@ export async function deployComposeHA(options) {
958
1033
  perfAsync('deploy.ha.compose.setupFiles.standby', () =>
959
1034
  setupServerFiles(standbyIp, sshKeyPath, projectName, serviceOpts),
960
1035
  ),
961
- perfAsync('deploy.ha.compose.openReplPort.primary', () =>
962
- openReplicationPort(primaryIp, standbyIp, sshKeyPath),
1036
+ perfAsync('deploy.ha.compose.openWgPortUfw.primary', () =>
1037
+ openWireguardPortUfw(primaryIp, standbyIp, sshKeyPath),
963
1038
  ),
964
- perfAsync('deploy.ha.compose.openReplPort.standby', () =>
965
- openReplicationPort(standbyIp, primaryIp, sshKeyPath),
1039
+ perfAsync('deploy.ha.compose.openWgPortUfw.standby', () =>
1040
+ openWireguardPortUfw(standbyIp, primaryIp, sshKeyPath),
966
1041
  ),
967
- // Open 5433 in the Hetzner Cloud firewall for both peers. UFW is the
968
- // host-level layer and was already configured above; without these two
969
- // calls the Cloud Firewall (Pulumi-declared with only 22/80/443) silently
970
- // drops every replication packet at the edge. Both directions because
971
- // post-failover the standby becomes primary and needs its own 5433 open
972
- // for the new standby (now the old primary) to reconnect.
973
- perfAsync('deploy.ha.compose.cloudFirewall5433.primary', () =>
974
- openReplicationPortHetznerFirewall(
1042
+ // Open the WireGuard tunnel port (UDP 51821) in the Hetzner Cloud firewall
1043
+ // for both peers. UFW is the host-level layer configured above; without
1044
+ // these two calls the Cloud Firewall (Pulumi-declared with only 22/80/443)
1045
+ // silently drops the WG handshake at the edge and the tunnel never comes
1046
+ // up. Both directions because post-failover the standby becomes primary and
1047
+ // the WG endpoints reverse the promoted node must already admit the old
1048
+ // primary's WG traffic.
1049
+ perfAsync('deploy.ha.compose.cloudFirewallWg.primary', () =>
1050
+ openWireguardPortHetznerFirewall(
975
1051
  `${projectName}-${environment}-primary`,
976
1052
  standbyIp,
977
1053
  apiToken,
978
1054
  ),
979
1055
  ),
980
- perfAsync('deploy.ha.compose.cloudFirewall5433.standby', () =>
981
- openReplicationPortHetznerFirewall(
1056
+ perfAsync('deploy.ha.compose.cloudFirewallWg.standby', () =>
1057
+ openWireguardPortHetznerFirewall(
982
1058
  `${projectName}-${environment}-standby`,
983
1059
  primaryIp,
984
1060
  apiToken,
@@ -1186,42 +1262,45 @@ export async function deployComposeHA(options) {
1186
1262
  );
1187
1263
  }
1188
1264
 
1189
- // Expose raw PostgreSQL on port 5433 for replication (supavisor owns 5432).
1190
- // The docker-compose.replication.yml overlay maps db:5432 → host:5433 on
1191
- // both nodes so pg_basebackup + streaming can reach the peer. Fan out
1192
- // across primary + standby the overlay write + db-only `compose up`
1193
- // touch disjoint VPS hosts and need to land on both before
1194
- // configurePrimaryReplication grants pub access.
1195
- //
1196
- // TODO(wireguard-transport): tunnel bring-up Task 6. This overlay (direct
1197
- // port-mapping, no TLS cert bind-mount) is the interim pre-WireGuard
1198
- // transport; Task 6 replaces it with the wg0 tunnel.
1199
- const replOverlay = 'version: "3.8"\nservices:\n db:\n ports:\n - "5433:5432"\n';
1265
+ // Write the WireGuard replication overlay (docker-compose.replication.yml) to
1266
+ // both nodes. It (a) publishes db:5432 → host:REPL_PORT so the socat relay has
1267
+ // a plaintext postgres to forward to, and (b) declares the `repl-gateway`
1268
+ // socat relay bound to THIS node's own tunnel IP (WG_PRIMARY_IP on the
1269
+ // primary, WG_STANDBY_IP on the standby). The relay is NOT started here —
1270
+ // socat binds the tunnel IP, which only exists after wg0 is up; the tunnel is
1271
+ // brought up (and the gateways started) inside configure{Primary,Standby}
1272
+ // Replication below. We DO recreate the db service now so its REPL_PORT
1273
+ // mapping is live before the relay attaches. Fan out — both nodes are
1274
+ // disjoint hosts.
1200
1275
  await Promise.all(
1201
- [primaryIp, standbyIp].map(async (ip) => {
1276
+ [
1277
+ [primaryIp, WG_PRIMARY_IP],
1278
+ [standbyIp, WG_STANDBY_IP],
1279
+ ].map(async ([ip, selfWgIp]) => {
1202
1280
  await sshRunAsync(
1203
1281
  ip,
1204
1282
  sshKeyPath,
1205
- `cat > /opt/${projectName}/docker-compose.replication.yml << 'REPL'\n${replOverlay}REPL`,
1283
+ `cat > /opt/${projectName}/docker-compose.replication.yml << 'REPL'\n${buildReplicationOverlay(selfWgIp)}REPL`,
1206
1284
  { timeout: 10_000 },
1207
1285
  );
1208
- // Recreate only the db service with the new port mapping
1286
+ // Recreate only the db service with the new port mapping (the relay is
1287
+ // started later, once wg0 exists).
1209
1288
  await sshRunAsync(
1210
1289
  ip,
1211
1290
  sshKeyPath,
1212
- `cd /opt/${projectName} && docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.replication.yml up -d db 2>&1`,
1291
+ `cd /opt/${projectName} && docker compose ${REPL_COMPOSE_FLAGS} up -d db 2>&1`,
1213
1292
  { timeout: 60_000 },
1214
1293
  );
1215
1294
  }),
1216
1295
  );
1217
1296
 
1218
1297
  // Configure PostgreSQL streaming replication.
1219
- // Wrap the entire setup (primary + standby + slot creation + pg_basebackup)
1220
- // so the perf trace shows the headline replication cost — typical 30-90s,
1221
- // dominated by pg_basebackup for the standby seed.
1298
+ // Wrap the entire setup (tunnel + gateways + primary + standby + slot +
1299
+ // pg_basebackup) so the perf trace shows the headline replication cost —
1300
+ // typical 30-90s, dominated by pg_basebackup for the standby seed.
1222
1301
  await perfAsync('deploy.ha.replication.setup', async () => {
1223
1302
  onProgress('Configuring PostgreSQL replication on primary...');
1224
- await configurePrimaryReplication(primaryIp, sshKeyPath, projectName);
1303
+ await configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName);
1225
1304
 
1226
1305
  onProgress('Configuring standby as hot replica...');
1227
1306
  let replicationOk = false;
@@ -1288,8 +1367,8 @@ export async function deployComposeHA(options) {
1288
1367
  lastState: replLastState,
1289
1368
  allowDegraded,
1290
1369
  fixHint:
1291
- 'Verify the replication port (5433) is open between the two regions and the standby ' +
1292
- 'completed pg_basebackup.',
1370
+ 'Verify the WireGuard tunnel port (UDP 51821) is open between the two regions, wg0 ' +
1371
+ 'is up on both nodes, and the standby completed pg_basebackup.',
1293
1372
  });
1294
1373
  if (replActive) {
1295
1374
  p.log.success('Streaming replication active');