@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 +6 -0
- package/assets/compose.yaml +6 -2
- package/dist/cli.mjs +96 -18
- package/package.json +1 -1
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.
|
package/assets/compose.yaml
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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",
|
|
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);
|