@or3/cloud 0.1.35 → 0.1.37

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/README.md CHANGED
@@ -32,6 +32,8 @@ npx @or3/cloud init --public --domain cloud.example.com
32
32
 
33
33
  OR3 Cloud uses Caddy to obtain and renew HTTPS certificates. It does not
34
34
  change your DNS, firewall, Cloudflare, or Tailscale settings.
35
+ The managed public profile forwards its proxy-trust setting into Nuxt runtime
36
+ configuration so HTTPS API requests are not redirected back to themselves.
35
37
 
36
38
  ## Day-to-day commands
37
39
 
@@ -52,6 +54,10 @@ npx @or3/cloud restart
52
54
 
53
55
  Do not run `docker compose down --volumes` on a normal deployment: it deletes
54
56
  the application data. Use `backup` before an update or any destructive action.
57
+ When an adopted legacy deployment used a different container UID, `update`
58
+ rebuilds the data from its checksummed pre-update backup as the hardened
59
+ runtime user. It does not recursively rewrite file ownership in place, and a
60
+ failed migration restores the recorded legacy root owner and snapshot.
55
61
  For a destructive purge, export a fresh backup to a new directory on another
56
62
  filesystem first; `remove --purge-data --yes` verifies that export before it
57
63
  will delete anything.
@@ -8,6 +8,10 @@ services:
8
8
  environment:
9
9
  HOST: 0.0.0.0
10
10
  PORT: 3000
11
+ # Nuxt only applies nested runtime-config overrides from NUXT_* names.
12
+ # Bridge the managed OR3 setting so Caddy's sanitized forwarding headers
13
+ # are trusted and HTTPS enforcement cannot redirect back to itself.
14
+ NUXT_SECURITY_PROXY_TRUST_PROXY: "${OR3_TRUST_PROXY:-false}"
11
15
  OR3_BASIC_AUTH_DB_PATH: /data/auth.sqlite
12
16
  OR3_SQLITE_DB_PATH: /data/sync.sqlite
13
17
  OR3_STORAGE_FS_ROOT: /data/storage
@@ -54,9 +58,9 @@ services:
54
58
  - CMD-SHELL
55
59
  - >-
56
60
  if [ -x /nodejs/bin/node ]; then
57
- exec /nodejs/bin/node -e "fetch('http://127.0.0.1:3000/api/health?deep=true').then(async r=>{const b=await r.json().catch(()=>({}));if(!r.ok||b.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))";
61
+ exec /nodejs/bin/node -e "const fs=require('node:fs');try{fs.accessSync('/data',fs.constants.R_OK|fs.constants.W_OK);for(const p of [process.env.OR3_BASIC_AUTH_DB_PATH,process.env.OR3_SQLITE_DB_PATH].filter(Boolean)){const fd=fs.openSync(p,'r+');fs.closeSync(fd)}}catch{process.exit(1)}fetch('http://127.0.0.1:3000/api/health?deep=true').then(async r=>{const b=await r.json().catch(()=>({}));if(!r.ok||b.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))";
58
62
  elif [ -x /usr/local/bin/node ]; then
59
- exec /usr/local/bin/node -e "fetch('http://127.0.0.1:3000/api/health?deep=true').then(async r=>{const b=await r.json().catch(()=>({}));if(!r.ok||b.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))";
63
+ exec /usr/local/bin/node -e "const fs=require('node:fs');try{fs.accessSync('/data',fs.constants.R_OK|fs.constants.W_OK);for(const p of [process.env.OR3_BASIC_AUTH_DB_PATH,process.env.OR3_SQLITE_DB_PATH].filter(Boolean)){const fd=fs.openSync(p,'r+');fs.closeSync(fd)}}catch{process.exit(1)}fetch('http://127.0.0.1:3000/api/health?deep=true').then(async r=>{const b=await r.json().catch(()=>({}));if(!r.ok||b.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))";
60
64
  else exit 127; fi
61
65
  interval: 10s
62
66
  timeout: 5s
package/dist/cli.mjs CHANGED
@@ -27,7 +27,7 @@ import { dirname, basename, isAbsolute, join, resolve, sep } from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import { createGunzip } from "node:zlib";
29
29
  var execFile = promisify(execFileCallback);
30
- var PACKAGE_VERSION = "0.1.35";
30
+ var PACKAGE_VERSION = "0.1.37";
31
31
  var IMAGE_REPOSITORY = "ghcr.io/saluana/or3-chat";
32
32
  var ASSET_ROOT = resolve(fileURLToPath(new URL("../assets/", import.meta.url)));
33
33
  var STATE_SCHEMA_VERSION = 1;
@@ -489,11 +489,16 @@ async function assertSafeComposeBinding(directory, mode, env) {
489
489
  throw new Error("Resolved Compose configuration must publish OR3 port 3000 only on 127.0.0.1. Refusing to expose the application directly.");
490
490
  }
491
491
  }
492
- var HEALTH_SCRIPT = "fetch('http://127.0.0.1:3000/api/health?deep=true').then(async response=>{const body=await response.json().catch(()=>({}));if(!response.ok||body.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))";
492
+ var HEALTH_SCRIPT = "const fs=require('node:fs');try{fs.accessSync('/data',fs.constants.R_OK|fs.constants.W_OK);for(const path of [process.env.OR3_BASIC_AUTH_DB_PATH,process.env.OR3_SQLITE_DB_PATH].filter(Boolean)){const fd=fs.openSync(path,'r+');fs.closeSync(fd)}}catch{process.exit(1)}fetch('http://127.0.0.1:3000/api/health?deep=true').then(async response=>{const body=await response.json().catch(()=>({}));if(!response.ok||body.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))";
493
493
  var MAINTENANCE_SCRIPT = "fetch('http://127.0.0.1:3000/api/health?deep=true').then(async response=>{const body=await response.json().catch(()=>({}));const m=body?.providers?.sync?.details?.maintenance;if(m)console.log(JSON.stringify(m))}).catch(()=>{})";
494
494
  var CONTAINER_NODE = "/nodejs/bin/node";
495
495
  var LEGACY_CONTAINER_NODE = "/usr/local/bin/node";
496
- var CONTAINER_HEALTH_SHELL = `if [ -x ${CONTAINER_NODE} ]; then exec ${CONTAINER_NODE} -e "$1"; elif [ -x ${LEGACY_CONTAINER_NODE} ]; then exec ${LEGACY_CONTAINER_NODE} -e "$1"; else exit 127; fi`;
496
+ var CONTAINER_NODE_SHELL = `if [ -x ${CONTAINER_NODE} ]; then exec ${CONTAINER_NODE} -e "$1"; elif [ -x ${LEGACY_CONTAINER_NODE} ]; then exec ${LEGACY_CONTAINER_NODE} -e "$1"; else exit 127; fi`;
497
+ var MANAGED_RUNTIME_UID = 65532;
498
+ var MANAGED_RUNTIME_GID = 65532;
499
+ function containerNodeCommand(script) {
500
+ return ["sh", "-c", CONTAINER_NODE_SHELL, "or3-node", script];
501
+ }
497
502
  async function waitForDeepHealthWithArgs(composeCommand, directory, secrets = []) {
498
503
  const deadline = Date.now() + DEEP_HEALTH_TIMEOUT_MS;
499
504
  let lastError = "health check did not complete";
@@ -503,11 +508,7 @@ async function waitForDeepHealthWithArgs(composeCommand, directory, secrets = []
503
508
  "exec",
504
509
  "-T",
505
510
  "or3",
506
- "sh",
507
- "-c",
508
- CONTAINER_HEALTH_SHELL,
509
- "or3-health",
510
- HEALTH_SCRIPT
511
+ ...containerNodeCommand(HEALTH_SCRIPT)
511
512
  ], directory);
512
513
  if (result.ok)
513
514
  return;
@@ -520,6 +521,65 @@ async function ensureDocker() {
520
521
  await requireCommand("docker", ["info"], "Docker Engine");
521
522
  await requireCommand("docker", ["compose", "version"], "Docker Compose v2");
522
523
  }
524
+ async function managedVolumeRootOwnership(image, volume) {
525
+ const result = await run("docker", [
526
+ "run",
527
+ "--rm",
528
+ "--network",
529
+ "none",
530
+ "--read-only",
531
+ "--user",
532
+ "0:0",
533
+ "--cap-drop",
534
+ "ALL",
535
+ "-v",
536
+ `${volume}:/data:ro`,
537
+ "--entrypoint",
538
+ "sh",
539
+ image,
540
+ "-c",
541
+ "stat -c '%u:%g' /data"
542
+ ]);
543
+ if (!result.ok)
544
+ throw new Error(`Could not inspect managed volume ${volume}. ${result.stderr.trim()}`);
545
+ const match = result.stdout.trim().match(/^(\d+):(\d+)$/);
546
+ if (!match)
547
+ throw new Error(`Managed volume ${volume} returned an invalid root ownership value.`);
548
+ return { uid: Number(match[1]), gid: Number(match[2]) };
549
+ }
550
+ async function setManagedVolumeRootOwnership(image, volume, ownership) {
551
+ if (!Number.isSafeInteger(ownership.uid) || ownership.uid < 0 || !Number.isSafeInteger(ownership.gid) || ownership.gid < 0) {
552
+ throw new Error("Refusing an invalid managed volume root UID/GID.");
553
+ }
554
+ const result = await run("docker", [
555
+ "run",
556
+ "--rm",
557
+ "--network",
558
+ "none",
559
+ "--read-only",
560
+ "--user",
561
+ "0:0",
562
+ "--security-opt",
563
+ "no-new-privileges:true",
564
+ "--cap-drop",
565
+ "ALL",
566
+ "--cap-add",
567
+ "CHOWN",
568
+ "-v",
569
+ `${volume}:/data`,
570
+ "--entrypoint",
571
+ "sh",
572
+ image,
573
+ "-c",
574
+ `chown ${ownership.uid}:${ownership.gid} /data`
575
+ ]);
576
+ if (!result.ok)
577
+ throw new Error(`Could not set managed volume ${volume} root ownership. ${result.stderr.trim()}`);
578
+ const actual = await managedVolumeRootOwnership(image, volume);
579
+ if (actual.uid !== ownership.uid || actual.gid !== ownership.gid) {
580
+ throw new Error(`Managed volume ${volume} root ownership verification failed.`);
581
+ }
582
+ }
523
583
  async function portAvailable(port) {
524
584
  return new Promise((resolvePromise) => {
525
585
  const server = createServer();
@@ -1657,12 +1717,34 @@ async function updateCommand(directory, flags) {
1657
1717
  const backup = await createBackup(loaded.directory, state, env);
1658
1718
  await updatePending(loaded.directory, state, { backupId: backup.backupId });
1659
1719
  const nextEnv = { ...env, OR3_VERSION: targetVersion, OR3_IMAGE: targetImage };
1660
- await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(nextEnv));
1720
+ const previousRootOwnership = await managedVolumeRootOwnership(targetImage, state.volumeName);
1721
+ const migrateLegacyVolume = previousRootOwnership.uid !== MANAGED_RUNTIME_UID || previousRootOwnership.gid !== MANAGED_RUNTIME_GID;
1661
1722
  try {
1723
+ await stopProject(loaded.directory, state.mode);
1724
+ if (migrateLegacyVolume) {
1725
+ await setManagedVolumeRootOwnership(targetImage, state.volumeName, {
1726
+ uid: MANAGED_RUNTIME_UID,
1727
+ gid: MANAGED_RUNTIME_GID
1728
+ });
1729
+ }
1730
+ await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(nextEnv));
1731
+ if (migrateLegacyVolume) {
1732
+ await restoreVolumeArchive(loaded.directory, state.mode, nextEnv, backup.backupDir);
1733
+ }
1662
1734
  await startProject(loaded.directory, state.mode, nextEnv);
1663
1735
  } catch (error) {
1664
1736
  await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(oldEnv));
1665
- await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir);
1737
+ try {
1738
+ await stopProject(loaded.directory, state.mode).catch(() => {
1739
+ return;
1740
+ });
1741
+ if (migrateLegacyVolume) {
1742
+ await setManagedVolumeRootOwnership(targetImage, state.volumeName, previousRootOwnership);
1743
+ }
1744
+ await restoreBackupData(loaded.directory, state, oldEnv, backup.backupDir);
1745
+ } catch (restoreError) {
1746
+ throw new Error(`Update to ${targetVersion} failed, and automatic backup restoration also failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}. Original update error: ${error instanceof Error ? error.message : String(error)}`);
1747
+ }
1666
1748
  const oldDigest = await imageDigest(state.image);
1667
1749
  state.imageDigest = oldDigest;
1668
1750
  state.lastError = `Update to ${targetVersion} failed and was restored: ${redact(error instanceof Error ? error.message : String(error), secretValues(oldEnv))}`;
@@ -2316,9 +2398,7 @@ async function runCredentialsResetScript(directory, mode, values) {
2316
2398
  "-e",
2317
2399
  `OR3_RESET_ADMIN_PASSWORD=${values.adminPassword}`,
2318
2400
  "or3",
2319
- CONTAINER_NODE,
2320
- "-e",
2321
- script
2401
+ ...containerNodeCommand(script)
2322
2402
  ])
2323
2403
  ], directory);
2324
2404
  if (!result.ok)
@@ -2339,9 +2419,7 @@ async function verifyCredentialsInsideContainer(directory, mode, values) {
2339
2419
  "-e",
2340
2420
  `OR3_RESET_ADMIN_PASSWORD=${values.adminPassword}`,
2341
2421
  "or3",
2342
- CONTAINER_NODE,
2343
- "-e",
2344
- CREDENTIALS_VERIFY_SCRIPT
2422
+ ...containerNodeCommand(CREDENTIALS_VERIFY_SCRIPT)
2345
2423
  ])
2346
2424
  ], directory);
2347
2425
  if (!result.ok)
@@ -2451,7 +2529,7 @@ async function statusCommand(directory) {
2451
2529
  await printMaintenanceSummary(directory, state.mode);
2452
2530
  }
2453
2531
  async function printMaintenanceSummary(directory, mode) {
2454
- const result = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", CONTAINER_NODE, "-e", MAINTENANCE_SCRIPT])], directory);
2532
+ const result = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", ...containerNodeCommand(MAINTENANCE_SCRIPT)])], directory);
2455
2533
  if (!result.ok || !result.stdout.trim())
2456
2534
  return;
2457
2535
  try {
@@ -2596,7 +2674,7 @@ ${result.stderr}`);
2596
2674
  console.log(`Purged: data volumes, backups, .env, managed state, and compose files were deleted. The verified export at ${exportReceipt.destination} is the remaining copy.`);
2597
2675
  }
2598
2676
  async function probeDeepHealth(directory, mode) {
2599
- const result = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", CONTAINER_NODE, "-e", HEALTH_SCRIPT])], directory);
2677
+ const result = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", ...containerNodeCommand(HEALTH_SCRIPT)])], directory);
2600
2678
  if (result.ok)
2601
2679
  return "ok";
2602
2680
  const ps = await run("docker", [...composeArgs(directory, mode, ["ps", "-q", "or3"])], directory);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@or3/cloud",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "description": "Install and operate the OR3 Cloud container distribution",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0",