@or3/cloud 0.1.36 → 0.1.38

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
 
@@ -56,6 +58,12 @@ When an adopted legacy deployment used a different container UID, `update`
56
58
  rebuilds the data from its checksummed pre-update backup as the hardened
57
59
  runtime user. It does not recursively rewrite file ownership in place, and a
58
60
  failed migration restores the recorded legacy root owner and snapshot.
61
+ Updates also replace the generated Compose/Caddy files from the target CLI.
62
+ Those files are checksummed into the pre-update backup and restored on a failed
63
+ update, rollback, restore, or interrupted-operation recovery.
64
+ When selecting an exact version, run the matching CLI package, for example
65
+ `npx --yes @or3/cloud@0.1.38 update --to 0.1.38`; the CLI refuses mismatched
66
+ package and image versions so their generated assets cannot drift.
59
67
  For a destructive purge, export a fresh backup to a new directory on another
60
68
  filesystem first; `remove --purge-data --yes` verifies that export before it
61
69
  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
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.36";
30
+ var PACKAGE_VERSION = "0.1.38";
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;
@@ -794,15 +794,107 @@ async function readPassword(flags) {
794
794
  validatePassword(password);
795
795
  return password;
796
796
  }
797
+ function managedAssetNames(mode) {
798
+ return ["compose.yaml", ...mode === "public" ? ["compose.public.yaml", "Caddyfile"] : []];
799
+ }
800
+ async function installManagedAssets(directory, assets) {
801
+ await mkdir(directory, { recursive: true, mode: 448 });
802
+ const token = randomBytes(4).toString("hex");
803
+ const staged = [];
804
+ try {
805
+ for (const [name, contents] of assets) {
806
+ const destination = join(directory, name);
807
+ const replacement = `${destination}.next-${token}`;
808
+ const entry = {
809
+ destination,
810
+ replacement
811
+ };
812
+ staged.push(entry);
813
+ await writeFile(replacement, contents, { mode: 420 });
814
+ await chmod(replacement, 420);
815
+ if (await fileExists(destination)) {
816
+ entry.rollback = `${destination}.previous-${token}`;
817
+ await copyFile(destination, entry.rollback);
818
+ await chmod(entry.rollback, 420);
819
+ }
820
+ }
821
+ const applied = [];
822
+ try {
823
+ for (const entry of staged) {
824
+ await rename(entry.replacement, entry.destination);
825
+ applied.push(entry);
826
+ await chmod(entry.destination, 420);
827
+ }
828
+ } catch (error) {
829
+ for (const entry of applied.reverse()) {
830
+ if (entry.rollback)
831
+ await rename(entry.rollback, entry.destination);
832
+ else
833
+ await rm(entry.destination, { force: true });
834
+ }
835
+ throw error;
836
+ }
837
+ } finally {
838
+ for (const entry of staged) {
839
+ await rm(entry.replacement, { force: true }).catch(() => {
840
+ return;
841
+ });
842
+ if (entry.rollback)
843
+ await rm(entry.rollback, { force: true }).catch(() => {
844
+ return;
845
+ });
846
+ }
847
+ }
848
+ }
797
849
  async function copyAssets(directory, mode) {
798
- await copyFile(join(ASSET_ROOT, "compose.yaml"), join(directory, "compose.yaml"));
799
- await chmod(join(directory, "compose.yaml"), 420);
800
- if (mode === "public") {
801
- await copyFile(join(ASSET_ROOT, "compose.public.yaml"), join(directory, "compose.public.yaml"));
802
- await copyFile(join(ASSET_ROOT, "Caddyfile"), join(directory, "Caddyfile"));
803
- await chmod(join(directory, "compose.public.yaml"), 420);
804
- await chmod(join(directory, "Caddyfile"), 420);
850
+ const assets = new Map;
851
+ for (const name of managedAssetNames(mode)) {
852
+ assets.set(name, await readFile(join(ASSET_ROOT, name)));
853
+ }
854
+ await installManagedAssets(directory, assets);
855
+ }
856
+ async function snapshotManagedAssets(directory, mode, backupDir) {
857
+ const assetDir = join(backupDir, "managed-assets");
858
+ await mkdir(assetDir, { recursive: true, mode: 448 });
859
+ await chmod(assetDir, 448);
860
+ const checksums = {};
861
+ for (const name of managedAssetNames(mode)) {
862
+ const destination = join(assetDir, name);
863
+ await copySecure(join(directory, name), destination);
864
+ checksums[name] = await sha256File(destination);
865
+ }
866
+ return checksums;
867
+ }
868
+ async function verifiedManagedAssetContents(backupPath, manifest) {
869
+ const checksums = manifest.managedAssetSha256;
870
+ if (!checksums)
871
+ return;
872
+ const expected = managedAssetNames(manifest.mode).sort();
873
+ const actual = Object.keys(checksums).sort();
874
+ if (actual.length !== expected.length || actual.some((name, index) => name !== expected[index])) {
875
+ throw new Error(`Backup ${manifest.backupId} has an invalid managed asset inventory.`);
876
+ }
877
+ const assets = new Map;
878
+ for (const name of expected) {
879
+ const expectedSha = checksums[name];
880
+ if (!expectedSha || !/^[0-9a-f]{64}$/i.test(expectedSha)) {
881
+ throw new Error(`Backup ${manifest.backupId} has an invalid checksum for managed asset ${name}.`);
882
+ }
883
+ const source = join(backupPath, "managed-assets", name);
884
+ const actualSha = await sha256File(source);
885
+ if (actualSha !== expectedSha) {
886
+ throw new Error(`Backup managed asset checksum mismatch for ${name}. Expected ${expectedSha}, got ${actualSha}.`);
887
+ }
888
+ assets.set(name, await readFile(source));
805
889
  }
890
+ return assets;
891
+ }
892
+ async function restoreManagedAssets(directory, backupPath, manifest) {
893
+ const assets = await verifiedManagedAssetContents(backupPath, manifest);
894
+ if (!assets)
895
+ return false;
896
+ await installManagedAssets(directory, assets);
897
+ return true;
806
898
  }
807
899
  async function checkPublicPrerequisites(domain) {
808
900
  validateDomain(domain);
@@ -1161,6 +1253,7 @@ async function createBackup(directory, state, env) {
1161
1253
  await stopProject(directory, state.mode);
1162
1254
  await volumeArchive(directory, state.mode, env, backupDir);
1163
1255
  await copySecure(deploymentPaths(directory).env, join(backupDir, "config.env"));
1256
+ const managedAssetSha256 = await snapshotManagedAssets(directory, state.mode, backupDir);
1164
1257
  const manifest = {
1165
1258
  schemaVersion: 1,
1166
1259
  backupId,
@@ -1171,6 +1264,7 @@ async function createBackup(directory, state, env) {
1171
1264
  dataSha256: await sha256File(join(backupDir, "data.tgz")),
1172
1265
  dataBytes: volumeSize,
1173
1266
  configSha256: await sha256File(join(backupDir, "config.env")),
1267
+ managedAssetSha256,
1174
1268
  mode: state.mode,
1175
1269
  domain: state.domain,
1176
1270
  composeProject: state.composeProject,
@@ -1219,6 +1313,7 @@ async function readManifest(backupPath) {
1219
1313
  if (configActual !== manifest.configSha256)
1220
1314
  throw new Error(`Backup configuration checksum mismatch for ${manifest.backupId}.`);
1221
1315
  }
1316
+ await verifiedManagedAssetContents(backupPath, manifest);
1222
1317
  return manifest;
1223
1318
  }
1224
1319
  async function dataVolumeFreeBytes(directory, mode, env) {
@@ -1259,6 +1354,7 @@ async function restoreBackupData(directory, state, env, backupPath) {
1259
1354
  stopAttempted = true;
1260
1355
  await stopProject(directory, state.mode);
1261
1356
  await copySecure(join(backupPath, "config.env"), deploymentPaths(directory).env);
1357
+ await restoreManagedAssets(directory, backupPath, manifest);
1262
1358
  await restoreVolumeArchive(directory, state.mode, backupEnv, backupPath);
1263
1359
  const restoredEnv = parseEnv(await readText(deploymentPaths(directory).env));
1264
1360
  await startProject(directory, state.mode, restoredEnv);
@@ -1490,9 +1586,13 @@ async function recoverCommand(directory) {
1490
1586
  throw new Error(`Backup ${manifest.backupId} configuration does not match its manifest.`);
1491
1587
  }
1492
1588
  assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
1589
+ if (pending.targetVersion !== PACKAGE_VERSION) {
1590
+ throw new Error(`This interrupted update targets OR3 ${pending.targetVersion}. Run \`npx --yes @or3/cloud@${pending.targetVersion} recover\` so the image and generated assets match.`);
1591
+ }
1493
1592
  try {
1494
1593
  if (pending.targetImageDigest)
1495
1594
  await pullAndRequireImage(pending.targetImage, pending.targetImageDigest, `OR3 ${pending.targetVersion}`);
1595
+ await copyAssets(loaded.directory, loaded.state.mode);
1496
1596
  await startProject(loaded.directory, loaded.state.mode, loaded.env);
1497
1597
  } catch (replacementError) {
1498
1598
  await restoreBackupData(loaded.directory, loaded.state, loaded.env, backupPath);
@@ -1627,6 +1727,11 @@ async function backupExportCommand(directory, backupId, destination) {
1627
1727
  for (const file of ["data.tgz", "config.env", "manifest.json"]) {
1628
1728
  await copySecure(join(backupPath, file), join(dest, file));
1629
1729
  }
1730
+ if (manifest.managedAssetSha256) {
1731
+ for (const name of managedAssetNames(manifest.mode)) {
1732
+ await copySecure(join(backupPath, "managed-assets", name), join(dest, "managed-assets", name));
1733
+ }
1734
+ }
1630
1735
  const dataSha = await sha256File(join(dest, "data.tgz"));
1631
1736
  if (dataSha !== manifest.dataSha256) {
1632
1737
  throw new Error(`Exported data.tgz checksum mismatch for ${manifest.backupId}. Expected ${manifest.dataSha256}, got ${dataSha}.`);
@@ -1637,6 +1742,7 @@ async function backupExportCommand(directory, backupId, destination) {
1637
1742
  throw new Error(`Exported config.env checksum mismatch for ${manifest.backupId}.`);
1638
1743
  }
1639
1744
  }
1745
+ await readManifest(dest);
1640
1746
  const destinationDevice = (await stat(dest)).dev;
1641
1747
  await mkdir(deploymentPaths(directory).exports, { recursive: true, mode: 448 });
1642
1748
  const receipt = {
@@ -1650,7 +1756,12 @@ async function backupExportCommand(directory, backupId, destination) {
1650
1756
  };
1651
1757
  await writeSecure(join(deploymentPaths(directory).exports, `${manifest.backupId}.json`), `${JSON.stringify(receipt, null, 2)}
1652
1758
  `);
1653
- const bytes = (await stat(join(dest, "data.tgz"))).size + (await stat(join(dest, "config.env"))).size + (await stat(join(dest, "manifest.json"))).size;
1759
+ let bytes = (await stat(join(dest, "data.tgz"))).size + (await stat(join(dest, "config.env"))).size + (await stat(join(dest, "manifest.json"))).size;
1760
+ if (manifest.managedAssetSha256) {
1761
+ for (const name of managedAssetNames(manifest.mode)) {
1762
+ bytes += (await stat(join(dest, "managed-assets", name))).size;
1763
+ }
1764
+ }
1654
1765
  console.log(`Exported backup ${manifest.backupId} (${bytes} bytes) to ${dest}`);
1655
1766
  console.log("The exported copy contains credentials and secrets. It is owner-only (0600); keep it off-host.");
1656
1767
  if ((await stat(backupPath)).dev === destinationDevice) {
@@ -1697,6 +1808,9 @@ async function updateCommand(directory, flags) {
1697
1808
  const targetVersion = stringFlag(flags, "to")?.trim() ?? PACKAGE_VERSION;
1698
1809
  if (!isVersion(targetVersion))
1699
1810
  throw new Error(`--to must be a complete semantic version such as ${PACKAGE_VERSION}.`);
1811
+ if (targetVersion !== PACKAGE_VERSION) {
1812
+ throw new Error(`This CLI contains deployment assets for OR3 ${PACKAGE_VERSION}. Run \`npx --yes @or3/cloud@${targetVersion} update --to ${targetVersion}\` so the image and generated assets match.`);
1813
+ }
1700
1814
  if (targetVersion === state.appVersion)
1701
1815
  throw new Error(`The deployment is already on OR3 ${targetVersion}.`);
1702
1816
  const targetImage = imageFor(targetVersion);
@@ -1728,6 +1842,7 @@ async function updateCommand(directory, flags) {
1728
1842
  });
1729
1843
  }
1730
1844
  await writeSecure(deploymentPaths(loaded.directory).env, serializeEnv(nextEnv));
1845
+ await copyAssets(loaded.directory, state.mode);
1731
1846
  if (migrateLegacyVolume) {
1732
1847
  await restoreVolumeArchive(loaded.directory, state.mode, nextEnv, backup.backupDir);
1733
1848
  }
@@ -2738,15 +2853,19 @@ export {
2738
2853
  validatePassword,
2739
2854
  supportedImageArchitectures,
2740
2855
  stateFromEnv,
2856
+ snapshotManagedAssets,
2741
2857
  serializeInitialCredentials,
2742
2858
  serializeEnv,
2743
2859
  selectPruneTargets,
2860
+ restoreManagedAssets,
2744
2861
  requiredArchiveSpace,
2745
2862
  redact,
2746
2863
  purgeVolumesFromState,
2747
2864
  parseFlags,
2748
2865
  parseEnv,
2866
+ managedAssetNames,
2749
2867
  isVersion,
2868
+ copyAssets,
2750
2869
  checkResolvedLoopbackBinding,
2751
2870
  buildEnv,
2752
2871
  buildCredentialsResetScript,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@or3/cloud",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "description": "Install and operate the OR3 Cloud container distribution",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0",