This commit is contained in:
2026-04-29 14:34:02 +00:00
parent 15c987b335
commit 583d286783
10 changed files with 424 additions and 191 deletions
+7 -5
View File
@@ -12,7 +12,7 @@ BareCloud is a **free, self-service dashboard** for launching Bare OS (Pear) boo
1. **Linux** host (bind-mount for per-instance Corestores into the shared Pear chroot).
2. **`tmux`** on `PATH` (**required** for `POST /api/booters` / headless Pear) — resumable browser consoles and background Pear use the same detached session model.
3. **Node.js 20+** and npm.
4. **Pear** installed under `/opt/pear-home` (or set `PEAR_HOME` when building the jail) — see `scripts/host/install-host-stack.sh` in this repos parent tree or install Pear per [Pear Getting Started](https://docs.pears.com/guide/getting-started.html).
4. **Pear** installed (BareCloud uses `/root/.nvm/versions/node/v20.20.2/bin/pear` for all instances). **Bootstrap shared Pear state once** on the host using the same `PEAR_HOME` BareCloud will use (default `/opt/pear-home`, or `BARECLOUD_PEAR_HOME` / `PEAR_HOME`), e.g. `PEAR_HOME=/opt/pear-home pear versions --json` — see `scripts/host/install-host-stack.sh` in this repos parent tree or [Pear Getting Started](https://docs.pears.com/guide/getting-started.html).
5. **Chroot jail** built at `/var/lib/barecloud/pear-jail` (default) or override with `BARECLOUD_CHROOT_JAIL`:
```bash
sudo bash scripts/host/build-pear-jail.sh
@@ -30,7 +30,7 @@ npm run dev
- **API + terminal WebSocket:** [http://127.0.0.1:3000](http://127.0.0.1:3000) (default `PORT`).
- **UI:** [http://127.0.0.1:5173](http://127.0.0.1:5173) — Vite dev server proxies `/api` (including WebSocket upgrades) to port 3000.
On non-Linux dev machines, terminal attach may require `BARECLOUD_ALLOW_NONLINUX_JAIL=1` (Corestore bind-mount is skipped — **not** for production).
On non-Linux dev machines, terminal behavior is best-effort and not production-supported.
## Environment variables
@@ -44,9 +44,9 @@ Copy `[.env.example](.env.example)` to `.env`. BareCloud reads `**BARECLOUD_*`**
| `BARECLOUD_BASE_PATH` | URL prefix for API + static app (same as `NEXT_PUBLIC_BASE_PATH` for builds). |
| `BARECLOUD_CORESTORE_ROOT` | Host root for instance dirs (default `/user-data/corestores`). |
| `BARECLOUD_PEAR_BOOT_LINK` | `pear://…` for `pear run --no-ask`. |
| `BARECLOUD_PEAR_HOME`, `BARECLOUD_PEAR_BIN` | Shared Pear install / CLI path (defaults `/opt/pear-home`, `pear` on `PATH`). |
| `BARECLOUD_SESSION_HOME_LAYOUT` | Per-booter `HOME` under `<id>/session-home` (default **on** except Windows). `0` = legacy layout with `BARE_OS_HOST_DATA` only. |
| `BARECLOUD_PEAR_PATH_SESSION_BIN` | Per-instance Pear bin precedence for `<id>/session-home/.config/pear/bin`. Default **on**; set `0` to force shared/global Pear resolution. |
| `BARECLOUD_PEAR_HOME` | Shared **Pear state** (`PEAR_HOME`): precedence `BARECLOUD_PEAR_HOME` → host `PEAR_HOME` → `/opt/pear-home`. Bootstrap once on the host (e.g. `PEAR_HOME=/opt/pear-home pear versions --json`) so new booters do not repeat full Pear install. CLI runtime is fixed to `/root/.nvm/versions/node/v20.20.2/bin/pear` for all instances. |
| `BARECLOUD_SESSION_HOME_LAYOUT` | Default **on** (Linux): per-booter `HOME` under `<id>/session-home` (Bare OS under `~/.bare-os`); `PEAR_HOME` stays **shared** as above. `0` = legacy: one shared `HOME`/`PEAR_HOME` and `BARE_OS_HOST_DATA` per booter. |
| `BARECLOUD_PEAR_PATH_SESSION_BIN` | Per-instance Pear bin precedence for `<id>/session-home/.config/pear/bin`. Default **off** (all booters use global Pear runtime); set `1` to enable session-bin precedence. |
| `BARECLOUD_USE_PROOT`, `BARECLOUD_PROOT_BIN`, `BARECLOUD_PROOT_ARGS` | Linux opt-in: wrap Pear in `proot` (fragile with RocksDB). |
| `BARECLOUD_USE_NETNS` | Linux: run each Pear in its **own network namespace** (default **on**) — separate `127.0.0.1` and ports; set `0` to disable. Needs `ip`/`iptables` + usually **root**. |
| `BARECLOUD_NETNS_OUT_IFACE`, `BARECLOUD_NETNS_DISABLE_NAT`, `BARECLOUD_NETNS_DNS` | Optional: outbound iface for MASQUERADE (default: auto-detect route); set `BARECLOUD_NETNS_DISABLE_NAT=1` for loopback-only. DNS inside netns uses `BARECLOUD_NETNS_DNS` (comma/space-separated), otherwise non-loopback nameservers from host `/etc/resolv.conf`, fallback `1.1.1.1,9.9.9.9`. |
@@ -72,6 +72,7 @@ Legacy references to `**BARECLOUD_CHROOT_JAIL`** / Docker-only booters apply onl
- SQLite database: `data/barecloud.db` (override with `BARECLOUD_DATA_DIR`).
- Per-booter Corestore: `<BARECLOUD_CORESTORE_ROOT>/<uuid>/`.
- With session-home layout (default on Linux), **`PEAR_HOME`** is **shared** across booters (see `BARECLOUD_PEAR_HOME`); **`HOME`** is `<uuid>/session-home` so Bare OS data under `~/.bare-os` stays per instance. **Many concurrent booters** therefore share one Pear runtime tree — warm it once on the host so launches do not repeat full Pear install. If you see updater or cache contention, tune **`BARE_OS_PEAR_UPDATER_*`** (forwarded into Pear from the host; see `lib/pm2-pear.ts`).
- Each instance stores **`pear_boot_link`** (the `pear://…` tmux/terminal use). At launch: optional JSON body on `POST /api/booters` — `{ "pearBootLink": "pear://…" }` — then `BARECLOUD_PEAR_BOOT_LINK`, then the demo default. Use a link your Pear peers already know, or you will see **key is not known** / TRUST until the runtime learns it. `GET /api/config` exposes `pearBootLinkDefault` (effective host default).
- **Expired instances** (`expires_at` in the past) are purged on an **hourly** schedule (`lib/cron.ts`, started from `server.ts`) and once at **process startup** (`purgeInactiveBooters` in `lib/booter-manager.ts`). Purge runs **`removeBooterStack`**: kills the Pear tmux session for that id, tears down netns as applicable, deletes the Corestore directory (unless `BARECLOUD_DELETE_CORESTORE_ON_REMOVE=0`), and removes the SQLite row.
- New instances get **`expires_at = now + 7 days`**. **“Extend seven more days”** sets **`expires_at = now + 7 days`** from the moment you click (and updates `last_accessed`). Opening the console or polling status **does not** extend the deadline.
@@ -79,6 +80,7 @@ Legacy references to `**BARECLOUD_CHROOT_JAIL`** / Docker-only booters apply onl
## Security notes
- There are **no accounts**. Anyone with an instance UUID can hit the APIs unless you layer authentication at the edge.
- **Shared `PEAR_HOME`:** Pear-level cache and config under the shared home are **not** isolated per instance (unlike each booters `session-home` / Corestore). Do not treat that tree as a per-user secret store.
- **GET `/api/booters`** only returns rows when you pass known `?ids=` — it never dumps every row.
- Run BareCloud with sufficient privilege to `**mount --bind**` Corestores into the jail (typically root).
- **`BARECLOUD_USE_NETNS` (default on Linux):** creating namespaces, veth pairs, and NAT rules requires **root** or capabilities (`CAP_NET_ADMIN`, `CAP_NET_RAW`, and iptables/nft compatibility). Set `BARECLOUD_USE_NETNS=0` to disable netns; otherwise Pear start will fail if privileges are missing.
+78 -28
View File
@@ -11,6 +11,10 @@ type Props = {
booterId: string;
};
type TerminalControlMessage =
| { barecloudTerminalControl: 1; type: "flow_status"; status: string }
| { barecloudTerminalControl: 1; type: string };
function appBasePath(): string {
return (import.meta.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/$/, "");
}
@@ -149,6 +153,12 @@ function shouldAutoRestartAfterPearCleanExit(buf: string): boolean {
return t.includes("reconnect the console") || t.includes("supervised instance");
}
/** Treat any terminal `[exited]` marker as "nothing to attach to" and trigger the 3s restart flow. */
function shouldAutoRestartAfterExitedAttach(buf: string): boolean {
const t = stripAnsiForMatch(buf).toLowerCase();
return t.includes("[exited]");
}
/** Shown when the console keeps dropping — usually network, browser sleep, or a proxy in front of the site. */
function terminalConnectionHelp(): string {
return [
@@ -164,6 +174,7 @@ export function Terminal({ booterId }: Props) {
const fitRef = useRef<FitAddon | null>(null);
const [status, setStatus] = useState<"connecting" | "open" | "error">("connecting");
const [errorDetail, setErrorDetail] = useState<string | null>(null);
const [flowStatus, setFlowStatus] = useState<string | null>(null);
const [kernelRebootBanner, setKernelRebootBanner] = useState<{
seconds: number;
detail: string;
@@ -287,22 +298,25 @@ export function Terminal({ booterId }: Props) {
let resizePostTimer: ReturnType<typeof setTimeout> | null = null;
let lastResizePosted = { cols: 0, rows: 0 };
const postResize = async (cols: number, rows: number) => {
if (cols < 2 || rows < 1) return;
if (cols === lastResizePosted.cols && rows === lastResizePosted.rows) return;
lastResizePosted = { cols, rows };
try {
await fetch(`${basePath}/api/terminal/${encodeURIComponent(booterId)}/resize`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cols, rows }),
});
} catch {
/* ignore */
}
};
const scheduleResizeNotify = () => {
if (resizePostTimer) clearTimeout(resizePostTimer);
resizePostTimer = setTimeout(() => {
resizePostTimer = null;
const cols = term.cols;
const rows = term.rows;
if (cols < 2 || rows < 1) return;
if (cols === lastResizePosted.cols && rows === lastResizePosted.rows) return;
lastResizePosted = { cols, rows };
void fetch(`${basePath}/api/terminal/${encodeURIComponent(booterId)}/resize`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cols, rows }),
}).catch(() => {
/* ignore */
});
void postResize(term.cols, term.rows);
}, 120);
};
@@ -342,6 +356,7 @@ export function Terminal({ booterId }: Props) {
const outputMatchBufRef = { current: "" };
const kernelRebootOfferedRef = { current: false };
const pearCleanExitRestartScheduledRef = { current: false };
const sawExitedAttachRef = { current: false };
let kernelRebootInterval: ReturnType<typeof setInterval> | null = null;
let postPearRestartEstablishTimer: ReturnType<typeof setTimeout> | null = null;
@@ -382,7 +397,7 @@ export function Terminal({ booterId }: Props) {
/* After the first successful session, stay on "open" so the xterm is not covered by a blur overlay. */
if (!welcomeShown) setStatus("connecting");
const sec = Math.max(1, Math.ceil(afterMs / 1000));
term.writeln(`\x1b[33m[BareCloud]\x1b[0m ${note} Retrying in ${sec}s… (attempt ${attempt}/${maxAttempts})`);
setFlowStatus(`${note} Retrying in ${sec}s… (${attempt}/${maxAttempts})`);
reconnectTimer = setTimeout(() => establishWs(), afterMs);
};
@@ -394,6 +409,7 @@ export function Terminal({ booterId }: Props) {
setKernelRebootBanner(null);
attempt = 0;
clearReconnect();
setFlowStatus(null);
establishWs();
};
kernelReconnectRef.current = performKernelReconnect;
@@ -428,10 +444,8 @@ export function Terminal({ booterId }: Props) {
outputMatchBufRef.current = "";
clearReconnect();
term.clear();
term.writeln(
"\x1b[36m[BareCloud]\x1b[0m Pear exited cleanly with nothing to attach to. The console will request a fresh Pear session in 3 seconds.",
);
term.writeln("");
setFlowStatus("Reconnecting terminal…");
setStatus("connecting");
setTimeout(async () => {
if (cancelled) return;
try {
@@ -444,20 +458,30 @@ export function Terminal({ booterId }: Props) {
if (!res.ok) {
throw new Error(j.error ?? "Restart failed");
}
term.writeln("\x1b[36m[BareCloud]\x1b[0m Restart requested — reconnecting…");
term.writeln("");
scheduleEstablishAfterPearRestart();
pearCleanExitRestartScheduledRef.current = false;
} catch (e) {
pearCleanExitRestartScheduledRef.current = false;
term.writeln(
`\x1b[31m[BareCloud]\x1b[0m ${e instanceof Error ? e.message : "Restart failed"}. Refresh the page or use Restart instance in the sidebar.`,
setFlowStatus(null);
setErrorDetail(
`${e instanceof Error ? e.message : "Restart failed"}. Refresh the page or use Restart instance in the sidebar.`,
);
term.writeln("");
setStatus("error");
}
}, 3000);
};
const parseTerminalControl = (text: string): TerminalControlMessage | null => {
if (!text.startsWith("{")) return null;
try {
const msg = JSON.parse(text) as TerminalControlMessage;
if ((msg as { barecloudTerminalControl?: number }).barecloudTerminalControl !== 1) return null;
return msg;
} catch {
return null;
}
};
const establishWs = () => {
if (cancelled) return;
clearReconnect();
@@ -495,12 +519,9 @@ export function Terminal({ booterId }: Props) {
wasOpenRef.current = true;
setStatus("open");
setErrorDetail(null);
setFlowStatus(null);
if (!welcomeShown) {
welcomeShown = true;
term.writeln("\x1b[36m[BareCloud]\x1b[0m Connected — your Bare OS console is ready.");
term.writeln("");
} else {
term.writeln("\x1b[36m[BareCloud]\x1b[0m Reconnected.");
}
requestAnimationFrame(() => {
runFit();
@@ -580,6 +601,9 @@ export function Terminal({ booterId }: Props) {
return;
}
if (wasOpen && ev.code === 1000) {
if (!pearCleanExitRestartScheduledRef.current && sawExitedAttachRef.current) {
schedulePearCleanExitRestart();
}
return;
}
if (wasOpen && ev.code !== 1000) {
@@ -601,12 +625,24 @@ export function Terminal({ booterId }: Props) {
ws.onmessage = (ev) => {
const d = ev.data;
if (typeof d === "string") {
const control = parseTerminalControl(d);
if (control?.type === "flow_status") {
setFlowStatus(control.status.trim() || null);
return;
}
}
const chunk = typeof d === "string" ? d : new TextDecoder().decode(d as ArrayBuffer);
if (typeof d === "string") term.write(d);
else term.write(new Uint8Array(d as ArrayBuffer));
setFlowStatus((prev) => (prev ? null : prev));
const prev = outputMatchBufRef.current;
outputMatchBufRef.current = (prev + chunk).slice(-OUTPUT_MATCH_BUF_MAX);
const normalized = stripAnsiForMatch(outputMatchBufRef.current).toLowerCase();
if (normalized.includes("[exited]")) {
sawExitedAttachRef.current = true;
}
if (
!kernelRebootOfferedRef.current &&
shouldOfferPearFailureReboot(outputMatchBufRef.current)
@@ -619,6 +655,12 @@ export function Terminal({ booterId }: Props) {
shouldAutoRestartAfterPearCleanExit(outputMatchBufRef.current)
) {
schedulePearCleanExitRestart();
} else if (
!kernelRebootOfferedRef.current &&
!pearCleanExitRestartScheduledRef.current &&
shouldAutoRestartAfterExitedAttach(outputMatchBufRef.current)
) {
schedulePearCleanExitRestart();
}
};
};
@@ -636,7 +678,14 @@ export function Terminal({ booterId }: Props) {
barecloudDebug("Terminal: multiplex mode enabled, using legacy terminal stream fallback", { booterId });
}
establishWs();
void (async () => {
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
runFit();
await postResize(term.cols, term.rows);
if (!cancelled) establishWs();
})();
const d = term.onData((payload) => {
const s = activeWsRef.current;
@@ -669,6 +718,7 @@ export function Terminal({ booterId }: Props) {
clearKernelRebootTimers();
kernelRebootOfferedRef.current = false;
pearCleanExitRestartScheduledRef.current = false;
sawExitedAttachRef.current = false;
if (postPearRestartEstablishTimer) {
clearTimeout(postPearRestartEstablishTimer);
postPearRestartEstablishTimer = null;
@@ -743,7 +793,7 @@ export function Terminal({ booterId }: Props) {
{status === "connecting" && (
<div className="absolute left-0 right-0 top-0 z-10 flex items-center justify-center gap-2 border-b border-white/10 bg-zinc-950/95 py-2.5 text-sm text-white/90 shadow-md">
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
Connecting to console
{flowStatus ?? "Connecting to console…"}
</div>
)}
{status === "error" && (
+12 -2
View File
@@ -85,6 +85,10 @@ export function BooterPage() {
const [clearDialogOpen, setClearDialogOpen] = useState(false);
/** Increment after restart / storage clear so `<Terminal>` remounts with a fresh session. */
const [terminalResetKey, setTerminalResetKey] = useState(0);
const [consoleFlowStatus, setConsoleFlowStatus] = useState<string | null>(null);
const handleFlowStatusChange = useCallback((next: string | null) => {
setConsoleFlowStatus((prev) => (prev === next ? prev : next));
}, []);
const consoleHostRef = useRef<HTMLElement>(null);
const [consoleFullscreen, setConsoleFullscreen] = useState(false);
@@ -272,7 +276,11 @@ export function BooterPage() {
</div>
</div>
<div className="relative min-h-0 min-w-0 flex-1 overflow-visible">
<Terminal key={`${id}-${terminalResetKey}`} booterId={id} />
<Terminal
key={`${id}-${terminalResetKey}`}
booterId={id}
onFlowStatusChange={handleFlowStatusChange}
/>
</div>
</section>
@@ -303,7 +311,9 @@ export function BooterPage() {
<Activity className="mt-0.5 h-4 w-4 text-white/45" />
<div>
<div className="text-xs text-white/45">Status</div>
<div className="text-white/85">{loading ? "…" : uptimeLabel}</div>
<div className="text-white/85">
{loading ? "…" : (consoleFlowStatus ?? uptimeLabel)}
</div>
</div>
</div>
<Separator />
+49
View File
@@ -0,0 +1,49 @@
/* global module, __dirname */
module.exports = {
apps: [
{
name: "barecloud",
cwd: __dirname,
env_file: ".env",
script: "/usr/bin/env",
args: [
"bash",
"-lc",
[
"set -euo pipefail",
'CORESTORE_ROOT="${BARECLOUD_CORESTORE_ROOT:-/user-data/corestores}"',
'DATA_DIR="${BARECLOUD_DATA_DIR:-./data}"',
'install -d -m 0755 /user-data "$CORESTORE_ROOT" "$DATA_DIR"',
'chown -R root:root "$CORESTORE_ROOT" "$DATA_DIR"',
'chmod 0755 /user-data "$CORESTORE_ROOT" "$DATA_DIR"',
'rm -f "$CORESTORE_ROOT"/*/session-home/.config/pear/*.sock 2>/dev/null || true',
"exec npm run start",
].join(" && "),
],
interpreter: "none",
instances: 1,
exec_mode: "fork",
autorestart: true,
max_restarts: 40,
min_uptime: "10s",
kill_timeout: 10_000,
env: {
NODE_ENV: "production",
HOME: "/root",
PM2_HOME: "/root/.pm2",
USER: "root",
LOGNAME: "root",
// Disable quota auto-tagging by default to avoid xfs_io failures
// on transient pear.sock files under session-home.
BARECLOUD_CORESTORE_QUOTA_ENABLED: "1",
BARECLOUD_SESSION_HOME_LAYOUT: "1",
BARECLOUD_PEAR_PATH_SESSION_BIN: "0",
BARECLOUD_USE_NETNS: "1",
BARECLOUD_NETNS_OUT_IFACE: "pia",
BARECLOUD_NETNS_HAIRPIN: "1",
BARECLOUD_NETNS_HAIRPIN_PUBLIC_IP: "66.56.80.54",
},
},
],
};
+15 -5
View File
@@ -33,7 +33,7 @@ export function pearSessionHomeDir(booterId: string): string {
}
/**
* Default (non-Windows): Pear `HOME` is `<booter>/session-home` and Bare OS uses `~/.bare-os` there (no `BARE_OS_HOST_DATA`).
* Default (non-Windows): `HOME` is `<booter>/session-home` (Bare OS under `~/.bare-os`); `PEAR_HOME` is shared.
* Opt out: `BARECLOUD_SESSION_HOME_LAYOUT=0`.
* Windows defaults to off unless explicitly set to `1` (symlinks can require elevation).
*/
@@ -72,13 +72,13 @@ export function usePearSessionIsolation(): boolean {
/**
* When **true** with session-home layout, prepend `<booter>/session-home/.config/pear/bin` to `PATH`.
* Default **true** so each booter can resolve and run its own Pear install first.
* Set `BARECLOUD_PEAR_PATH_SESSION_BIN=0` to force shared/global Pear resolution.
* Default **false** so all booters resolve the shared/global Pear runtime first.
* Set `BARECLOUD_PEAR_PATH_SESSION_BIN=1` to prefer a per-session Pear shim.
*/
export function pearPathIncludeSessionBin(): boolean {
const v = process.env.BARECLOUD_PEAR_PATH_SESSION_BIN?.trim().toLowerCase();
if (v === "0" || v === "false" || v === "no" || v === "off") return false;
return true;
if (v === "1" || v === "true" || v === "yes" || v === "on") return true;
return false;
}
/** Log once at startup if proot is enabled but the binary is missing. */
@@ -115,3 +115,13 @@ export function deleteCorestoreOnRemove(): boolean {
const v = process.env.BARECLOUD_DELETE_CORESTORE_ON_REMOVE?.trim().toLowerCase();
return v !== "0" && v !== "false" && v !== "no";
}
/**
* Optional skeleton directory copied into each booter host-data dir on launch/reset.
* Use this to pre-seed trusted Pear/session-home state.
*/
export function booterSkeletonDir(): string | null {
const v = process.env.BARECLOUD_BOOTER_SKELETON_DIR?.trim();
if (!v) return null;
return path.resolve(v);
}
+37 -1
View File
@@ -1,5 +1,7 @@
import fs from "fs";
import { booterHostDataPath, deleteCorestoreOnRemove, pearBootLinkFromHostEnv } from "./barecloud-config";
import { execFileSync } from "child_process";
import path from "path";
import { booterHostDataPath, booterSkeletonDir, deleteCorestoreOnRemove, pearBootLinkFromHostEnv } from "./barecloud-config";
import { deleteBooterRecord, getBooterById, insertBooter, listExpiredBooterIds } from "./db";
import { teardownBooterNetns } from "./booter-netns";
import { ensureCorestoreQuota, removeCorestoreQuotaMapping } from "./corestore-quotas";
@@ -26,9 +28,42 @@ function startHeadlessPearInTmux(booterId: string): void {
ensureDetachedPearTmuxSession(booterId, defaultPearTermEnv());
}
function seedBooterFromSkeletonIfConfigured(targetDir: string): void {
const skeleton = booterSkeletonDir();
if (!skeleton) return;
if (!fs.existsSync(skeleton) || !fs.statSync(skeleton).isDirectory()) {
throw new Error(`BareCloud skeleton is not a directory: ${skeleton}`);
}
fs.mkdirSync(targetDir, { recursive: true });
try {
const from = skeleton.endsWith(path.sep) ? skeleton : `${skeleton}${path.sep}`;
const to = targetDir.endsWith(path.sep) ? targetDir : `${targetDir}${path.sep}`;
execFileSync("rsync", [
"-a",
"--delete",
"--exclude=**/*.sock",
"--exclude=**/*.lock",
from,
to,
]);
return;
} catch {
// fallback to built-in copy if rsync is unavailable
}
fs.cpSync(skeleton, targetDir, {
recursive: true,
force: true,
preserveTimestamps: true,
});
}
export async function launchBooter(opts: LaunchOpts): Promise<{ hostPort: number }> {
const dir = booterHostDataPath(opts.id);
fs.mkdirSync(dir, { recursive: true });
seedBooterFromSkeletonIfConfigured(dir);
ensureCorestoreQuota(opts.id, dir);
const pearBootLink = opts.pearBootLink?.trim() ? opts.pearBootLink.trim() : pearBootLinkFromHostEnv();
@@ -204,6 +239,7 @@ export async function clearBooterCorestore(id: string): Promise<void> {
try {
fs.rmSync(p, { recursive: true, force: true });
fs.mkdirSync(p, { recursive: true });
seedBooterFromSkeletonIfConfigured(p);
ensureCorestoreQuota(id, p);
} catch (e) {
throw new ClearCorestoreError(
+53 -4
View File
@@ -121,6 +121,38 @@ function pickProjectId(booterId: string, map: QuotaMap, used: Set<number>): numb
return candidate;
}
function pruneSocketInodes(rootDir: string): void {
const stack = [rootDir];
while (stack.length > 0) {
const cur = stack.pop()!;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(cur, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const p = path.join(cur, entry.name);
if (entry.isDirectory()) {
stack.push(p);
continue;
}
let st: fs.Stats;
try {
st = fs.lstatSync(p);
} catch {
continue;
}
if (!st.isSocket()) continue;
try {
fs.unlinkSync(p);
} catch {
/* ignore: runtime sockets may disappear between scan and unlink */
}
}
}
}
export function ensureCorestoreQuota(booterId: string, dirPath: string): void {
if (!quotasEnabled()) return;
if (!commandExists("xfs_quota")) {
@@ -149,11 +181,28 @@ export function ensureCorestoreQuota(booterId: string, dirPath: string): void {
/*
* Some hosts report `project -s` success without actually setting inode project IDs/inheritance.
* `xfs_io` forces both recursively.
* Socket inodes (e.g. Pear IPC sockets) are not supported by `xfs_io chattr/chproj`, so remove stale sockets first.
*/
execFileSync("xfs_io", ["-c", `chproj -R ${pid}`, "-c", "chattr -R +P", dirPath], {
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 2 * 1024 * 1024,
});
pruneSocketInodes(dirPath);
try {
execFileSync("xfs_io", ["-c", `chproj -R ${pid}`, "-c", "chattr -R +P", dirPath], {
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 2 * 1024 * 1024,
});
} catch (e) {
/*
* Some trees include inode types xfs_io cannot retag recursively (e.g. symlinks from seeded runtime state),
* which surfaces as "setprojid: Operation not supported". `project -s` may already have applied the project
* to the directory tree sufficiently for quota accounting, so accept that state instead of hard-failing launch.
*/
if (!projectTreeLooksSet(mountPoint, dirPath, pid)) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`BareCloud quotas: xfs_io retag failed and project is not set for ${dirPath} (id ${pid}): ${msg}`);
}
console.warn(
`[barecloud] quotas: xfs_io retag skipped for ${dirPath} (id ${pid}); continuing because project assignment is already set`,
);
}
if (!projectTreeLooksSet(mountPoint, dirPath, pid)) {
throw new Error(`BareCloud quotas: project id/inheritance is not set for ${dirPath} (id ${pid})`);
}
+55 -114
View File
@@ -1,4 +1,3 @@
import { execFileSync } from "child_process";
import fs from "fs";
import path from "path";
import {
@@ -12,6 +11,8 @@ import {
import { wrapPearSpawnWithNetnsIfNeeded } from "./booter-netns";
import { pearBootLinkForBooter } from "./pear-boot-link";
const DEFAULT_PEAR_BIN = "/root/.nvm/versions/node/v20.20.2/bin/pear";
/** Drop `<BARECLOUD_CORESTORE_ROOT>/…` segments so `which pear` never resolves to a per-instance session shim. */
function filterPathVarOfCorestores(pathVar: string): string {
let rr: string;
@@ -33,107 +34,9 @@ function filterPathVarOfCorestores(pathVar: string): string {
return kept.join(sep);
}
export function pearCliExecutable(booterId?: string): string {
const v = process.env.BARECLOUD_PEAR_BIN?.trim();
if (v) return path.resolve(v);
if (booterId && usePearSessionIsolation()) {
return ensurePerBooterPearCliShim(booterId);
}
const lookupBins: string[] = [];
lookupBins.push(path.join(pearHomeDir(), ".config/pear/bin"));
const pathForWhich = `${lookupBins.join(path.delimiter)}${path.delimiter}${filterPathVarOfCorestores(process.env.PATH ?? "")}`;
try {
const w = execFileSync("which", ["pear"], {
encoding: "utf8",
env: { ...process.env, PATH: pathForWhich },
}).trim();
if (w) return w;
} catch {
/* ignore */
}
return "pear";
}
/**
* Ensure each isolated booter has its own CLI entrypoint path so launch never resolves a host-global binary.
* We materialize a regular wrapper script in booter-local bin (not symlink), so recursive XFS project tagging
* (`xfs_io ... chattr -R +P`) can succeed on quota-managed corestore trees while still executing the host Pear CLI.
*/
function ensurePerBooterPearCliShim(booterId: string): string {
const sessionBin = path.join(pearSessionHomeDir(booterId), ".config/pear/bin");
const localPear = path.join(sessionBin, "pear");
fs.mkdirSync(sessionBin, { recursive: true });
try {
if (fs.existsSync(localPear)) fs.unlinkSync(localPear);
} catch {
/* ignore and continue */
}
const sharedPear = path.join(pearBinDir(), "pear");
if (fs.existsSync(sharedPear)) {
try {
writePearWrapper(localPear, sharedPear);
return localPear;
} catch {
/* fall through to which-based discovery */
}
}
try {
const w = execFileSync("which", ["pear"], {
encoding: "utf8",
env: { ...process.env, PATH: `${pearBinDir()}${path.delimiter}${filterPathVarOfCorestores(process.env.PATH ?? "")}` },
}).trim();
if (w) {
try {
writePearWrapper(localPear, w);
return localPear;
} catch {
return w;
}
}
} catch {
/* ignore */
}
return localPear;
}
function writePearWrapper(wrapperPath: string, targetPear: string): void {
const target = targetPear.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const script = `#!/usr/bin/env bash
exec "${target}" "$@"
`;
fs.writeFileSync(wrapperPath, script, { encoding: "utf8" });
fs.chmodSync(wrapperPath, 0o755);
}
/**
* Force first-time Pear initialization inside the booter's own HOME/PEAR_HOME.
* Without this, Pear can appear "already installed" from host-global state and skip per-instance setup.
*/
function ensurePerBooterPearBootstrap(
booterId: string,
pearExec: string,
cwd: string,
env: Record<string, string>,
): void {
if (!usePearSessionIsolation()) return;
const sessionHome = pearSessionHomeDir(booterId);
const marker = path.join(sessionHome, ".config/pear/.barecloud_bootstrapped");
if (fs.existsSync(marker)) return;
try {
execFileSync(pearExec, ["versions", "--json"], {
cwd,
env,
stdio: "ignore",
maxBuffer: 2 * 1024 * 1024,
});
fs.mkdirSync(path.dirname(marker), { recursive: true });
fs.writeFileSync(marker, `${new Date().toISOString()}\n`, { encoding: "utf8" });
console.info(`[barecloud] booter=${booterId} pear_bootstrap=ok`);
} catch (e) {
throw new Error(`BareCloud: pear bootstrap failed for booter=${booterId}: ${String(e)}`);
}
export function pearCliExecutable(): string {
// Hard-pinned global runtime for all booters.
return DEFAULT_PEAR_BIN;
}
/** Arguments for the pear binary: always `run --no-ask <link>` so tmux / PTY never prompts interactively. */
@@ -203,20 +106,25 @@ function pearBinDir(): string {
return path.join(pearHomeDir(), ".config/pear/bin");
}
/** Directory containing the configured global Pear runtime executable. */
function pearRuntimeBinDir(): string {
return path.dirname(pearCliExecutable());
}
/**
* Session layout: include `…/corestores/<id>/session-home/.config/pear/bin` on `PATH` by default so each booter
* resolves its own Pear install first. Set `BARECLOUD_PEAR_PATH_SESSION_BIN=0` to use shared/global Pear instead.
* Session layout: keep a global Pear runtime first by default; optional session-home shim can be prepended when enabled.
*/
function pearPathSessionHome(sessionHome: string, basePath: string): string {
const sessionBin = path.join(sessionHome, ".config/pear/bin");
const runtimeBin = pearRuntimeBinDir();
const sharedBin = pearBinDir();
if (path.resolve(sessionBin) === path.resolve(sharedBin)) {
if (path.resolve(sessionBin) === path.resolve(runtimeBin)) {
return `${sessionBin}:${basePath}`;
}
if (pearPathIncludeSessionBin()) {
return `${sessionBin}:${sharedBin}:${basePath}`;
return `${sessionBin}:${runtimeBin}:${sharedBin}:${basePath}`;
}
return `${sharedBin}:${basePath}`;
return `${runtimeBin}:${sharedBin}:${basePath}`;
}
function hostPathEnv(): { basePath: string; sslCert: string; nodeExtraCa: string } {
@@ -230,22 +138,54 @@ function hostPathEnv(): { basePath: string; sslCert: string; nodeExtraCa: string
}
/**
* HOME is `<hostData>/session-home`; omit `BARE_OS_HOST_DATA` so bare-os uses `~/.bare-os`.
* Pear uses `session-home/.config/pear` per booter (no shared symlink/bind — avoids RocksDB lock fights between instances).
* Seed a new per-booter Pear config from the host's already-installed runtime.
* This avoids "Installing Pear Runtime..." on first launch while keeping each booter isolated.
*/
function seedSessionPearConfigFromHostInstall(sessionHome: string): void {
const sessionPear = path.join(sessionHome, ".config", "pear");
const sessionCurrent = path.join(sessionPear, "current");
if (fs.existsSync(sessionCurrent)) return;
const hostPear = "/root/.config/pear";
if (!fs.existsSync(hostPear)) return;
fs.mkdirSync(sessionPear, { recursive: true });
const copyIfExists = (name: string) => {
const src = path.join(hostPear, name);
const dst = path.join(sessionPear, name);
if (!fs.existsSync(src) || fs.existsSync(dst)) return;
fs.cpSync(src, dst, { recursive: true, force: true });
};
// Minimal runtime seed sufficient to skip Pear runtime bootstrap.
copyIfExists("bin");
copyIfExists("by-dkey");
copyIfExists("current");
fs.mkdirSync(path.join(sessionPear, "corestores"), { recursive: true });
}
/**
* `HOME` is `<hostData>/session-home` so Bare OS uses `~/.bare-os` there (omit `BARE_OS_HOST_DATA`).
* `PEAR_HOME` is {@link pearHomeDir} (shared host tree) for global Pear runtime metadata.
* Keep `XDG_*` under session-home per booter so RocksDB/Corestore lock files remain isolated.
*/
function pearManagedEnvSessionHome(booterId: string, termEnv: string): Record<string, string> {
const sessionHome = pearSessionHomeDir(booterId);
const sharedPearHome = pearHomeDir();
fs.mkdirSync(sessionHome, { recursive: true });
fs.mkdirSync(path.join(sessionHome, ".config"), { recursive: true });
fs.mkdirSync(path.join(sessionHome, ".config/pear/bin"), { recursive: true });
fs.mkdirSync(path.join(sessionHome, ".local/share"), { recursive: true });
fs.mkdirSync(path.join(sessionHome, ".cache"), { recursive: true });
fs.mkdirSync(path.join(sessionHome, ".local/state"), { recursive: true });
seedSessionPearConfigFromHostInstall(sessionHome);
fs.mkdirSync(sharedPearHome, { recursive: true });
const { basePath, sslCert, nodeExtraCa } = hostPathEnv();
return {
HOME: sessionHome,
/* Prevent inherited global PEAR_HOME from tmux/server environment. */
PEAR_HOME: sessionHome,
/* Shared Pear runtime/cache; must be bootstrapped once on the host (see README). */
PEAR_HOME: sharedPearHome,
/*
* tmux sessions can inherit server env; explicitly pin XDG roots per booter so Pear never falls back
* to host-global config/data/cache paths.
@@ -273,6 +213,7 @@ function pearManagedEnvLegacy(booterId: string, termEnv: string): Record<string,
const coreDir = booterHostDataPath(booterId);
const home = pearHomeDir();
const { basePath, sslCert, nodeExtraCa } = hostPathEnv();
const runtimeBin = pearRuntimeBinDir();
const pearBD = pearBinDir();
return {
HOME: home,
@@ -290,7 +231,7 @@ function pearManagedEnvLegacy(booterId: string, termEnv: string): Record<string,
BARE_OS_HOST_DATA: coreDir,
BARE_OS_BOOT_TIMEOUT_MS: bareOsBootTimeoutMs(),
BARE_OS_NO_SPLASH: bareOsNoSplash(),
PATH: `${pearBD}:${basePath}`,
PATH: `${runtimeBin}:${pearBD}:${basePath}`,
SSL_CERT_FILE: sslCert,
NODE_EXTRA_CA_CERTS: nodeExtraCa,
TERM: termEnv,
@@ -310,6 +251,7 @@ export function pearManagedEnv(booterId: string, termEnv: string): Record<string
* Merge parent `process.env` with {@link pearManagedEnv}, then drop `XDG_*` entries from the parent.
* BareCloud often runs under systemd/cron with `XDG_CONFIG_HOME` etc. pointing at unrelated paths; Pear honors those
* and may print misleading `export PATH=…` lines (see {@link pearPathSessionHome} — instance dirs stay off `PATH` by default).
* Session layout: `PEAR_HOME` is shared; `HOME` / `XDG_*` are per booter.
*/
export function pearRegulatedChildEnv(booterId: string, termEnv: string): Record<string, string> {
const env = { ...process.env, ...pearManagedEnv(booterId, termEnv) } as Record<string, string>;
@@ -366,8 +308,7 @@ export function buildPearPtySpawn(booterId: string, termEnv: string): {
} {
const cwd = booterHostDataPath(booterId);
const env = pearRegulatedChildEnv(booterId, termEnv);
const pearExec = pearCliExecutable(booterId);
ensurePerBooterPearBootstrap(booterId, pearExec, cwd, env);
const pearExec = pearCliExecutable();
console.info(`[barecloud] booter=${booterId} pear_exec=${pearExec}`);
let inner: { file: string; args: string[]; cwd: string; env: Record<string, string> } = {
file: pearExec,
+70 -31
View File
@@ -43,6 +43,12 @@ type TerminalScrollControlMessage = {
lines: number;
};
type TerminalFlowStatusControlMessage = {
barecloudTerminalControl: 1;
type: "flow_status";
status: string;
};
function parseTerminalScrollControlMessage(data: RawData): TerminalScrollControlMessage | null {
let text: string;
if (typeof data === "string") {
@@ -80,6 +86,20 @@ function parseTerminalScrollControlMessage(data: RawData): TerminalScrollControl
}
}
function sendTerminalFlowStatus(clientWs: WebSocket, status: string): void {
if (clientWs.readyState !== WebSocket.OPEN) return;
const payload: TerminalFlowStatusControlMessage = {
barecloudTerminalControl: 1,
type: "flow_status",
status,
};
try {
clientWs.send(JSON.stringify(payload), { binary: false, compress: false });
} catch {
/* ignore */
}
}
function booterIdFromSecWebSocketProtocol(req: IncomingMessage): string {
const h = req.headers["sec-websocket-protocol"];
const raw = typeof h === "string" ? h : Array.isArray(h) ? h.join(",") : "";
@@ -401,19 +421,7 @@ export function pipeTerminalProxy(
return;
}
if (clientWs.readyState === WebSocket.OPEN) {
try {
const banner =
sessionUsesTmux && hadDetachedBeforeConnect
? "\r\n\x1b[90m[BareCloud]\x1b[0m Reconnected to your running session — your shell was kept open while you were away.\r\n"
: sessionUsesTmux
? "\r\n\x1b[90m[BareCloud]\x1b[0m Starting in a resumable session — you can close this tab and reopen this link later to pick up where you left off.\r\n"
: "\r\n\x1b[90m[BareCloud]\x1b[0m Spawning Pear in an interactive terminal (boot splash and installer). When you disconnect, your instance keeps running in tmux when enabled.\r\n";
clientWs.send(banner, { binary: false, compress: false });
} catch {
/* ignore */
}
}
/* Keep terminal output app-only: lifecycle/status messaging is shown in the UI flow panel. */
let lastResize: { cols: number; rows: number } | null =
initialSize != null ? { cols: initialSize.cols, rows: initialSize.rows } : null;
@@ -518,10 +526,56 @@ export function pipeTerminalProxy(
};
const WS_SEND_CHUNK = 24 * 1024;
let pearInstallNoticeCarry = "";
let bootingBannerShown = false;
function rewritePearInstallNotice(chunk: string): string {
const startNeedle = "To complete Pear installation, prepend the following to the system $PATH:";
const endNeedle = "Fix automatically with: pear run pear://runtime";
let input = pearInstallNoticeCarry + chunk;
pearInstallNoticeCarry = "";
let out = "";
while (true) {
const startIdx = input.indexOf(startNeedle);
if (startIdx === -1) break;
const blockStart = (() => {
const prevNl = input.lastIndexOf("\n", startIdx);
return prevNl === -1 ? 0 : prevNl + 1;
})();
const endIdx = input.indexOf(endNeedle, startIdx);
if (endIdx === -1) {
out += input.slice(0, blockStart);
pearInstallNoticeCarry = input.slice(blockStart);
input = "";
break;
}
const endLineIdx = input.indexOf("\n", endIdx);
if (endLineIdx === -1) {
out += input.slice(0, blockStart);
pearInstallNoticeCarry = input.slice(blockStart);
input = "";
break;
}
out += input.slice(0, blockStart);
if (!bootingBannerShown) {
bootingBannerShown = true;
sendTerminalFlowStatus(clientWs, "Trust accepted — downloading booter and starting Bare OS...");
}
input = input.slice(endLineIdx + 1);
}
return out + input;
}
pty.onData((data: string) => {
if (clientWs.readyState !== WebSocket.OPEN) return;
try {
const rewritten = rewritePearInstallNotice(data);
if (!rewritten) return;
const ws = clientWs as WebSocket & { bufferedAmount?: number };
if (ws.bufferedAmount >= WS_BUFFER_HIGH) {
try {
@@ -535,7 +589,7 @@ export function pipeTerminalProxy(
}, 30);
}
}
const buf = Buffer.from(data, "utf8");
const buf = Buffer.from(rewritten, "utf8");
for (let o = 0; o < buf.length; o += WS_SEND_CHUNK) {
const part = buf.subarray(o, Math.min(buf.length, o + WS_SEND_CHUNK));
clientWs.send(part.toString("utf8"), { binary: false, compress: false });
@@ -558,23 +612,8 @@ export function pipeTerminalProxy(
});
pty.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
if (clientWs.readyState === WebSocket.OPEN) {
try {
const sig = signal ?? 0;
const ec = exitCode ?? 0;
let seg =
" BareCloud will start the supervised instance again when this session closes. If it exits right away, check that peers are available on the network.";
if (sig === 0 && ec === 0) {
seg =
" Reconnect the console when you want a new interactive session; the supervised instance will be running in the background.";
}
clientWs.send(
`\r\n\x1b[90m[BareCloud]\x1b[0m Pear exited (exitCode=${exitCode ?? "null"}, signal=${sig}).${seg}\r\n`,
{ binary: false, compress: false },
);
} catch {
/* ignore */
}
if (process.env.BARECLOUD_DEBUG_WS === "1") {
console.error("[barecloud] terminal: pty_exit", booterId, { exitCode, signal: signal ?? 0 });
}
cleanup("pty_exit");
});
+48 -1
View File
@@ -117,6 +117,14 @@ function configureTmuxServerInput(): void {
} catch {
/* ignore */
}
try {
execFileSync(tmuxBin(), [...tmuxSocketArgs(), "set-option", "-g", "remain-on-exit", "off"], {
stdio: "ignore",
env: tmuxClientEnv(process.env),
});
} catch {
/* ignore */
}
for (const key of ["MouseDown3Pane", "MouseDown3Status", "MouseDown3Border"]) {
try {
execFileSync(tmuxBin(), [...tmuxSocketArgs(), "unbind-key", "-n", key], {
@@ -187,6 +195,14 @@ function configureTmuxSessionForWebTerminal(booterId: string): void {
} catch {
/* ignore */
}
try {
execFileSync(tmuxBin(), [...tmuxSocketArgs(), "set-option", "-t", target, "remain-on-exit", "off"], {
stdio: "ignore",
env: tmuxClientEnv(process.env),
});
} catch {
/* ignore */
}
try {
execFileSync(
tmuxBin(),
@@ -201,7 +217,7 @@ function configureTmuxSessionForWebTerminal(booterId: string): void {
}
}
export function hasTmuxPearSession(booterId: string): boolean {
function tmuxSessionExists(booterId: string): boolean {
if (!useTerminalTmuxPersistence()) return false;
try {
execFileSync(tmuxBin(), [...tmuxSocketArgs(), "has-session", "-t", tmuxExactSessionTarget(booterId)], {
@@ -214,6 +230,34 @@ export function hasTmuxPearSession(booterId: string): boolean {
}
}
function tmuxSessionHasLivePane(booterId: string): boolean {
if (!useTerminalTmuxPersistence()) return false;
try {
const out = execFileSync(
tmuxBin(),
[...tmuxSocketArgs(), "list-panes", "-t", tmuxExactSessionTarget(booterId), "-F", "#{pane_dead}"],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
env: tmuxClientEnv(process.env),
},
);
const states = out
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
if (states.length === 0) return false;
return states.some((s) => s !== "1");
} catch {
return false;
}
}
export function hasTmuxPearSession(booterId: string): boolean {
if (!tmuxSessionExists(booterId)) return false;
return tmuxSessionHasLivePane(booterId);
}
export function scrollTmuxPearSession(booterId: string, direction: "up" | "down", lines: number): boolean {
if (!useTerminalTmuxPersistence()) return false;
const count = Math.max(1, Math.min(200, Math.floor(lines)));
@@ -255,6 +299,9 @@ function tmuxNewSessionEnvArgs(booterId: string, termEnv: string): string[] {
/** Start Pear in a detached tmux session (no-op if session already exists). */
export function ensureDetachedPearTmuxSession(booterId: string, termEnv: string): void {
if (!useTerminalTmuxPersistence()) return;
if (tmuxSessionExists(booterId) && !tmuxSessionHasLivePane(booterId)) {
killTmuxPearSession(booterId, { force: true });
}
if (hasTmuxPearSession(booterId)) {
configureTmuxSessionForWebTerminal(booterId);
return;