QOL Updates

This commit is contained in:
2026-04-28 00:45:50 +00:00
parent fe24418cb0
commit 15c987b335
9 changed files with 416 additions and 52 deletions
+25 -1
View File
@@ -46,10 +46,11 @@ Copy `[.env.example](.env.example)` to `.env`. BareCloud reads `**BARECLOUD_*`**
| `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` | Set `1` to prepend `<id>/session-home/.config/pear/bin` to `PATH` again. Default **off** so Pear install text does not suggest `export PATH=…/corestores/<uuid>/…`. |
| `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_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`. |
| `BARECLOUD_NETNS_HAIRPIN`, `BARECLOUD_NETNS_HAIRPIN_PUBLIC_IP`, `BARECLOUD_NETNS_HAIRPIN_INTERNAL_CIDR` | Optional **NAT hairpin** for two instances on the **same** host to reach each others public endpoints: set `BARECLOUD_NETNS_HAIRPIN=1`, set `BARECLOUD_NETNS_HAIRPIN_PUBLIC_IP` to the hosts public IPv4, optional CIDR (default `10.0.0.0/8`). See **UDP / P2P and per-instance networking**. |
| `BARECLOUD_IP_BIN`, `BARECLOUD_IPTABLES_BIN` | Override paths for `ip` (iproute2) and `iptables` when using netns. |
| `BARECLOUD_BARE_OS_NO_SPLASH` | Set `1` to hide bare-os boot splash in Pear. |
| `BARE_OS_BOOT_TIMEOUT_MS` | Optional; forwarded from host env into Pear (bare-os boot wait). |
@@ -82,6 +83,28 @@ Legacy references to `**BARECLOUD_CHROOT_JAIL`** / Docker-only booters apply onl
- 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.
## UDP / P2P and per-instance networking
With **`BARECLOUD_USE_NETNS`** enabled (default on Linux), each instance runs in its own network namespace with a veth pair and **source NAT** (`MASQUERADE`) for outbound traffic. Return packets for flows that Pear initiates are accepted on **FORWARD** via **`conntrack`** (`RELATED,ESTABLISHED`), which matches what most UDP hole punching setups need through a Linux router.
**Host:** enable IPv4 forwarding (`sysctl net.ipv4.ip_forward=1`). BareCloud warns at runtime if `/proc/sys/net/ipv4/ip_forward` is not `1`. Use an `iptables`/`nft` stack compatible with the rules BareCloud installs.
**`BARECLOUD_NETNS_DISABLE_NAT=1`:** loopback-style isolation only — **not** suitable for WAN UDP hole punching.
**VPN:** If the host sends all egress through a VPN, every instance shares that path. **Inbound** UDP from the open internet may require your providers **port forwarding** (often off by default). Example: in [`tun/docker-compose.yml`](tun/docker-compose.yml), `VPN_PORT_FORWARDING=off` must be changed if you rely on predictable inbound ports through the VPN tunnel.
**Same BareCloud host, two instances:** Both use the **same** host public IPv4. Peers that connect via each others **public** address and port may need **NAT hairpin** (reflection). Set `BARECLOUD_NETNS_HAIRPIN=1` and **`BARECLOUD_NETNS_HAIRPIN_PUBLIC_IP`** to that IPv4 (e.g. from `curl -4 -s https://ifconfig.me`). Adjust **`BARECLOUD_NETNS_HAIRPIN_INTERNAL_CIDR`** only if guest addresses fall outside the default `10.0.0.0/8` (BareCloud assigns `10.x.y.z/24` per instance). Hairpin adds a shared `POSTROUTING` **MASQUERADE** rule; **BareCloud does not remove it** when an instance stops. To disable later, unset the env vars and delete the rule, for example:
`iptables -t nat -D POSTROUTING -s 10.0.0.0/8 -d YOUR_PUBLIC_IP -j MASQUERADE`
**Sysctl (optional):** If long-lived UDP sessions drop, consider tuning `net.netfilter.nf_conntrack_udp_timeout` and `net.netfilter.nf_conntrack_udp_timeout_stream` on the host.
**IPv6:** Per-instance netns setup is **IPv4-only** today.
## Pear runtime and Holepunch
UDP hole punching, DHT-based rendezvous, and related NAT traversal are implemented by the **Pear / Holepunch** stack (Hyperswarm, **HyperDHT**), not by BareCloud. The [HyperDHT](https://github.com/holepunchto/hyperdht) project documents hole punching for direct peer connectivity; BareCloud only supplies isolated Linux network namespaces and NAT on the host. Symmetric NAT, carrier-grade NAT, or restrictive firewalls may still require relay or app-level behavior provided by that stack.
## Production
```bash
@@ -129,6 +152,7 @@ npm run verify:pty
- **4410 pear_restart:** server closed the terminal WebSocket so a Pear/tmux restart can proceed; the UI reconnects.
- **4411 instance_deleted:** the instance was removed while the console was open; the UI shows an error and does not recreate tmux for a deleted booter.
- **Rate limiting:** launches are capped at **5 per hour per IP** (in-memory v1 guard).
- **UDP / P2P between instances:** confirm `ip_forward=1`, VPN port forwarding if you need inbound from the internet, optional hairpin env vars for **same-host** public-endpoint P2P, and conntrack UDP timeouts if sessions die mid-flight.
## Legacy Docker assets
+33 -4
View File
@@ -16,21 +16,50 @@
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
html,
body {
html {
height: 100%;
background-color: #05070c;
scrollbar-gutter: stable;
/* Viewport scrollbar (document / window scrolling). */
scrollbar-width: thin;
scrollbar-color: rgba(103, 212, 255, 0.45) rgba(255, 255, 255, 0.06);
}
html::-webkit-scrollbar {
width: 9px;
height: 9px;
}
html::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 999px;
}
html::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(103, 212, 255, 0.6), rgba(129, 140, 248, 0.52));
border: 2px solid rgba(5, 7, 12, 0.85);
border-radius: 999px;
}
html::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(125, 223, 255, 0.78), rgba(147, 159, 255, 0.72));
}
body {
background:
min-height: 100%;
background-color: transparent;
/* Fixed layers: one viewport backdrop — no tiling or banding when the page is taller than the screen. */
background-image:
radial-gradient(1200px 600px at 20% 0%, rgba(56, 189, 248, 0.18), transparent 55%),
radial-gradient(900px 500px at 85% 10%, rgba(99, 102, 241, 0.22), transparent 55%),
radial-gradient(700px 450px at 60% 90%, rgba(16, 185, 129, 0.12), transparent 55%),
linear-gradient(to bottom, #05070c, #05070c);
background-repeat: no-repeat;
background-attachment: fixed;
color: var(--foreground);
}
/* Thin, theme-matched scrollbars (Firefox + Chromium/WebKit). */
/* Nested scroll regions (main layout panels, cards, etc.). */
* {
scrollbar-width: thin;
scrollbar-color: rgba(103, 212, 255, 0.45) rgba(255, 255, 255, 0.06);
+18 -11
View File
@@ -6,9 +6,17 @@ import { cn } from "@/lib/utils";
export function MainLayout() {
const { pathname } = useLocation();
const isHome = pathname === "/";
/** Let the window scroll these routes so the browser scrollbar is primary (not a nested main scroller). */
const documentScrollRoute =
pathname === "/user-manual" || pathname === "/stats";
return (
<div className="flex h-dvh min-h-0 flex-col overflow-hidden">
<div
className={cn(
"flex min-h-0 flex-col",
documentScrollRoute ? "min-h-dvh" : "h-dvh overflow-hidden",
)}
>
<header className="sticky top-0 z-40 shrink-0 border-b border-white/10 bg-black/30 backdrop-blur-xl">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-3 px-4 py-3 sm:gap-4 sm:px-5 sm:py-4">
<Link to="/" className="flex items-center gap-2 font-semibold tracking-tight">
@@ -44,20 +52,19 @@ export function MainLayout() {
</header>
<main
className={cn(
"mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col px-5",
isHome ? "overflow-y-auto py-4 sm:py-8" : "overflow-y-auto py-5 sm:py-10",
"mx-auto flex w-full max-w-6xl flex-col px-5",
isHome
? "min-h-0 flex-1 overflow-y-auto py-4 sm:py-8"
: documentScrollRoute
? "py-5 sm:py-10"
: "min-h-0 flex-1 overflow-y-auto py-5 sm:py-10",
)}
>
<Outlet />
</main>
<footer
className={cn(
"shrink-0 border-t border-white/10 text-center text-[11px] text-white/45",
isHome ? "py-1" : "py-7",
)}
>
<div className={cn("mx-auto flex max-w-6xl flex-col items-center px-4", isHome ? "gap-0.5" : "gap-1")}>
<p className={cn("leading-snug", isHome && "hidden sm:block")}>
<footer className="shrink-0 border-t border-white/10 py-1 text-center text-[11px] text-white/45">
<div className="mx-auto flex max-w-6xl flex-col items-center gap-0.5 px-4">
<p className="hidden leading-snug sm:block">
BareCloud hosts Bare OS for the community. Instances are removed if not extended within seven days open yours
and tap extend before the deadline to keep them.
</p>
+71 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { format, formatDistanceToNow } from "date-fns";
import {
@@ -8,6 +8,8 @@ import {
Clock3,
Cpu,
Eraser,
Maximize2,
Minimize2,
RotateCcw,
ShieldOff,
Sparkles,
@@ -21,6 +23,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { forgetBooterId, rememberBooterId } from "@/lib/booter-storage";
import { barecloudDebug } from "@/lib/barecloud-debug";
import { responseJsonObject } from "@/lib/json-response";
@@ -82,6 +85,8 @@ 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 consoleHostRef = useRef<HTMLElement>(null);
const [consoleFullscreen, setConsoleFullscreen] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
@@ -93,6 +98,36 @@ export function BooterPage() {
window.history.replaceState({}, "", `${base}/booter/${encodeURIComponent(id)}`);
}, [id]);
useEffect(() => {
function syncFullscreen() {
setConsoleFullscreen(document.fullscreenElement === consoleHostRef.current);
}
document.addEventListener("fullscreenchange", syncFullscreen);
return () => document.removeEventListener("fullscreenchange", syncFullscreen);
}, []);
useEffect(() => {
return () => {
if (document.fullscreenElement) {
void document.exitFullscreen().catch(() => {});
}
};
}, []);
const toggleConsoleFullscreen = useCallback(async () => {
const el = consoleHostRef.current;
if (!el) return;
try {
if (document.fullscreenElement === el) {
await document.exitFullscreen();
} else {
await el.requestFullscreen();
}
} catch {
toast.error("Fullscreen is not available in this browser or context.");
}
}, []);
const refresh = useCallback(async () => {
try {
const statusUrl = `/api/status/${encodeURIComponent(id)}?touch=1`;
@@ -193,8 +228,15 @@ export function BooterPage() {
</header>
<div className="mx-auto flex min-h-0 w-full max-w-[1600px] flex-1 flex-col gap-4 overflow-visible px-3 py-3 sm:px-4 sm:py-4 lg:flex-row lg:items-stretch lg:gap-5 lg:overflow-hidden lg:px-5 lg:py-5">
<section className="flex min-h-[min(58dvh,680px)] min-w-0 flex-1 flex-col gap-3 overflow-visible lg:min-h-0">
<div className="flex shrink-0 flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
<section
ref={consoleHostRef}
className={cn(
"flex min-h-[min(58dvh,680px)] min-w-0 flex-1 flex-col gap-3 overflow-visible lg:min-h-0",
consoleFullscreen &&
"box-border min-h-[100dvh] w-full bg-[#05070c] px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 sm:px-5 sm:pt-5",
)}
>
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-base font-semibold tracking-tight text-white">Console</h2>
<p className="text-xs text-white/55">
@@ -202,9 +244,32 @@ export function BooterPage() {
more space.
</p>
</div>
<Badge variant="default" className="w-fit shrink-0">
Interactive
</Badge>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<Button
type="button"
variant="secondary"
size="sm"
className="rounded-xl"
title={consoleFullscreen ? "Leave fullscreen (Esc)" : "Use the whole screen for the console"}
aria-pressed={consoleFullscreen}
onClick={() => void toggleConsoleFullscreen()}
>
{consoleFullscreen ? (
<>
<Minimize2 className="mr-2 h-4 w-4" />
Exit fullscreen
</>
) : (
<>
<Maximize2 className="mr-2 h-4 w-4" />
Fullscreen
</>
)}
</Button>
<Badge variant="default" className="w-fit shrink-0">
Interactive
</Badge>
</div>
</div>
<div className="relative min-h-0 min-w-0 flex-1 overflow-visible">
<Terminal key={`${id}-${terminalResetKey}`} booterId={id} />
+62 -19
View File
@@ -1,5 +1,5 @@
import { Link, useSearchParams } from "react-router-dom";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
@@ -153,7 +153,11 @@ function useDebouncedValue<T>(value: T, delayMs: number): T {
return debounced;
}
/** Clears below sticky header (~5rem) + padding when using scrollIntoView / hash navigation. */
const TOPIC_SECTION_SCROLL_CLASS = "scroll-mt-28";
export function UserManualPage() {
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const [manIndex, setManIndex] = useState<ManIndex | null>(null);
const [loadingMan, setLoadingMan] = useState(true);
@@ -230,11 +234,29 @@ export function UserManualPage() {
setSearchParams(next, { replace: true });
}, [query, category, selectedPageKey, readerOpen, searchParams, setSearchParams]);
useLayoutEffect(() => {
const id = location.hash.replace(/^#/, "");
if (!id) return;
const el = document.getElementById(id);
if (!el) return;
requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "start" });
});
}, [location.pathname, location.hash]);
const rowVirtualizer = useVirtualizer({
count: filteredPages.length,
getScrollElement: () => resultsParentRef.current,
estimateSize: () => 88,
estimateSize: (index) => {
const page = filteredPages[index];
if (!page) return 96;
// Rough extra height for wrapped titles / long labels before DOM measurement runs.
const wrapBoost = Math.min(120, Math.floor((page.title.length + page.name.length) / 48) * 20);
return 96 + wrapBoost;
},
overscan: 8,
gap: 8,
getItemKey: (index) => filteredPages[index]?.key ?? index,
});
function refineByKeyword(term: string) {
@@ -389,18 +411,21 @@ export function UserManualPage() {
</h2>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{topics.map((topic) => (
<a
<Link
key={topic.id}
href={`#${topic.id}`}
to={`/user-manual#${topic.id}`}
className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-sm text-white/80 transition-colors hover:border-sky-400/40 hover:bg-sky-500/10 hover:text-white"
>
{topic.label}
</a>
</Link>
))}
</div>
</section>
<section id="overview" className="rounded-3xl border border-white/10 bg-black/20 p-6 shadow-2xl shadow-black/25">
<section
id="overview"
className={`${TOPIC_SECTION_SCROLL_CLASS} rounded-3xl border border-white/10 bg-black/20 p-6 shadow-2xl shadow-black/25`}
>
<h2 className="mb-3 flex items-center gap-2 text-xl font-semibold text-white">
<BookOpen className="h-5 w-5 text-sky-300" />
Overview: what BareCloud is and why it feels different
@@ -421,7 +446,10 @@ export function UserManualPage() {
</div>
</section>
<section id="quick-start" className="rounded-3xl border border-white/10 bg-black/20 p-6 shadow-2xl shadow-black/25">
<section
id="quick-start"
className={`${TOPIC_SECTION_SCROLL_CLASS} rounded-3xl border border-white/10 bg-black/20 p-6 shadow-2xl shadow-black/25`}
>
<h2 className="mb-3 flex items-center gap-2 text-xl font-semibold text-white">
<Rocket className="h-5 w-5 text-emerald-300" />
Quick start: your first successful flow
@@ -441,7 +469,10 @@ export function UserManualPage() {
</div>
</section>
<section id="lifecycle" className="rounded-3xl border border-white/10 bg-black/20 p-6 shadow-2xl shadow-black/25">
<section
id="lifecycle"
className={`${TOPIC_SECTION_SCROLL_CLASS} rounded-3xl border border-white/10 bg-black/20 p-6 shadow-2xl shadow-black/25`}
>
<h2 className="mb-3 flex items-center gap-2 text-xl font-semibold text-white">
<Clock3 className="h-5 w-5 text-violet-300" />
Instance lifecycle: retention, extension, and expiry
@@ -462,7 +493,10 @@ export function UserManualPage() {
</div>
</section>
<section id="terminal" className="rounded-3xl border border-white/10 bg-black/20 p-6 shadow-2xl shadow-black/25">
<section
id="terminal"
className={`${TOPIC_SECTION_SCROLL_CLASS} rounded-3xl border border-white/10 bg-black/20 p-6 shadow-2xl shadow-black/25`}
>
<h2 className="mb-3 flex items-center gap-2 text-xl font-semibold text-white">
<TerminalSquare className="h-5 w-5 text-sky-300" />
Terminal sessions: how interaction and reconnects work
@@ -482,7 +516,7 @@ export function UserManualPage() {
</div>
</section>
<section id="actions" className="grid grid-cols-1 gap-4 md:grid-cols-3">
<section id="actions" className={`${TOPIC_SECTION_SCROLL_CLASS} grid grid-cols-1 gap-4 md:grid-cols-3`}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@@ -526,7 +560,10 @@ export function UserManualPage() {
</Card>
</section>
<section id="limits" className="rounded-3xl border border-white/10 bg-black/20 p-6 shadow-2xl shadow-black/25">
<section
id="limits"
className={`${TOPIC_SECTION_SCROLL_CLASS} rounded-3xl border border-white/10 bg-black/20 p-6 shadow-2xl shadow-black/25`}
>
<h2 className="mb-3 flex items-center gap-2 text-xl font-semibold text-white">
<Zap className="h-5 w-5 text-amber-300" />
Limits and expectations
@@ -549,7 +586,7 @@ export function UserManualPage() {
<section
id="troubleshooting"
className="rounded-3xl border border-white/10 bg-black/20 p-6 shadow-2xl shadow-black/25"
className={`${TOPIC_SECTION_SCROLL_CLASS} rounded-3xl border border-white/10 bg-black/20 p-6 shadow-2xl shadow-black/25`}
>
<h2 className="mb-3 flex items-center gap-2 text-xl font-semibold text-white">
<LifeBuoy className="h-5 w-5 text-violet-300" />
@@ -574,7 +611,10 @@ export function UserManualPage() {
</div>
</section>
<section id="local-run" className="rounded-3xl border border-white/10 bg-black/30 p-6 shadow-2xl shadow-black/30">
<section
id="local-run"
className={`${TOPIC_SECTION_SCROLL_CLASS} rounded-3xl border border-white/10 bg-black/30 p-6 shadow-2xl shadow-black/30`}
>
<h2 className="mb-2 text-lg font-semibold text-white">Run Bare OS locally with Pear</h2>
<p className="mb-4 text-sm leading-relaxed text-white/70 sm:text-base">
Prefer local execution? Node.js is required (it includes npm). Use the local tutorial for the official Pear
@@ -588,7 +628,10 @@ export function UserManualPage() {
</Button>
</section>
<section id="man-pages" className="rounded-3xl border border-white/10 bg-black/30 p-6 shadow-2xl shadow-black/30">
<section
id="man-pages"
className={`${TOPIC_SECTION_SCROLL_CLASS} rounded-3xl border border-white/10 bg-black/30 p-6 shadow-2xl shadow-black/30`}
>
<div className="mb-4 space-y-2">
<h2 className="text-xl font-semibold text-white">Live Bare OS man pages</h2>
<p className="text-sm leading-relaxed text-white/70 sm:text-base">
@@ -697,14 +740,14 @@ export function UserManualPage() {
return (
<div
key={page.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
paddingBottom: "8px",
}}
>
<button
@@ -716,10 +759,10 @@ export function UserManualPage() {
: "border-white/10 bg-white/[0.02] hover:border-white/25 hover:bg-white/[0.05]"
}`}
>
<p className="text-sm font-medium text-white">
<p className="break-words text-sm font-medium text-white">
{page.name}({page.section})
</p>
<p className="text-xs text-white/65">{page.title}</p>
<p className="break-words text-xs text-white/65">{page.title}</p>
<p className="mt-1 text-[11px] uppercase tracking-[0.12em] text-sky-200/80">
{page.listCategory}
</p>
+4 -3
View File
@@ -72,12 +72,13 @@ export function usePearSessionIsolation(): boolean {
/**
* When **true** with session-home layout, prepend `<booter>/session-home/.config/pear/bin` to `PATH`.
* Default **false** so Pear's post-install text does not tell users to `export PATH=…/corestores/<uuid>/session-home/…`.
* Per-instance Pear state still lives under `HOME` (session-home); use `BARECLOUD_PEAR_BIN` for an absolute `pear` CLI.
* 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.
*/
export function pearPathIncludeSessionBin(): boolean {
const v = process.env.BARECLOUD_PEAR_PATH_SESSION_BIN?.trim().toLowerCase();
return v === "1" || v === "true" || v === "yes" || v === "on";
if (v === "0" || v === "false" || v === "no" || v === "off") return false;
return true;
}
/** Log once at startup if proot is enabled but the binary is missing. */
+58
View File
@@ -155,6 +155,63 @@ function outboundIfaces(): string[] {
return [...out];
}
const IPV4_DOTTED =
/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
/**
* Optional NAT hairpin / reflection: rewrite traffic from guest address space to the host public IPv4
* so two instances on the same BareCloud host can reach each other's observed public endpoints.
* Rules are global (not per-guestIp); BareCloud does not remove them in teardown — see README.
*/
function ensureHairpinReflectionRules(): void {
if (process.env.BARECLOUD_NETNS_DISABLE_NAT?.trim() === "1") return;
if (process.env.BARECLOUD_NETNS_HAIRPIN?.trim() !== "1") return;
const pub = process.env.BARECLOUD_NETNS_HAIRPIN_PUBLIC_IP?.trim();
if (!pub) {
console.warn(
"[barecloud] netns: BARECLOUD_NETNS_HAIRPIN=1 but BARECLOUD_NETNS_HAIRPIN_PUBLIC_IP is unset — skipping hairpin rules",
);
return;
}
if (!IPV4_DOTTED.test(pub)) {
console.warn(
`[barecloud] netns: BARECLOUD_NETNS_HAIRPIN_PUBLIC_IP must be an IPv4 address (got "${pub}") — skipping hairpin rules`,
);
return;
}
const cidr = process.env.BARECLOUD_NETNS_HAIRPIN_INTERNAL_CIDR?.trim() || "10.0.0.0/8";
if (!hairpinNatRuleExists(cidr, pub)) {
try {
hairpinNatRuleAdd(cidr, pub);
} catch (e) {
console.warn("[barecloud] netns: iptables hairpin POSTROUTING failed", e);
}
}
}
function hairpinNatRuleExists(cidr: string, publicIp: string): boolean {
try {
execFileSync(
iptablesBin(),
["-t", "nat", "-C", "POSTROUTING", "-s", cidr, "-d", publicIp, "-j", "MASQUERADE"],
{ stdio: "pipe" },
);
return true;
} catch {
return false;
}
}
function hairpinNatRuleAdd(cidr: string, publicIp: string): void {
execFileSync(
iptablesBin(),
["-t", "nat", "-A", "POSTROUTING", "-s", cidr, "-d", publicIp, "-j", "MASQUERADE"],
{ stdio: "pipe" },
);
}
function ensureNatAndForwardRules(vh: string, guestIp: string): void {
const enableNat = process.env.BARECLOUD_NETNS_DISABLE_NAT?.trim() !== "1";
const outDevs = [...new Set([...routedOutboundIfaces(guestIp, vh), ...outboundIfaces()])];
@@ -201,6 +258,7 @@ function ensureNatAndForwardRules(vh: string, guestIp: string): void {
"[barecloud] netns: no default route iface — set BARECLOUD_NETNS_OUT_IFACE or disable NAT with BARECLOUD_NETNS_DISABLE_NAT=1",
);
}
ensureHairpinReflectionRules();
}
function routedOutboundIfaces(guestIp: string, ingressIf: string): string[] {
+141 -8
View File
@@ -33,11 +33,15 @@ function filterPathVarOfCorestores(pathVar: string): string {
return kept.join(sep);
}
export function pearCliExecutable(): string {
export function pearCliExecutable(booterId?: string): string {
const v = process.env.BARECLOUD_PEAR_BIN?.trim();
if (v) return path.resolve(v);
const shimDir = path.join(pearHomeDir(), ".config/pear/bin");
const pathForWhich = `${shimDir}${path.delimiter}${filterPathVarOfCorestores(process.env.PATH ?? "")}`;
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",
@@ -50,6 +54,88 @@ export function pearCliExecutable(): string {
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)}`);
}
}
/** Arguments for the pear binary: always `run --no-ask <link>` so tmux / PTY never prompts interactively. */
export function pearRunArgv(link: string): string[] {
const u = link.trim();
@@ -87,15 +173,39 @@ function bareOsNoSplash(): string {
return "0";
}
function bareOsPearUpdaterPassthroughEnv(): Record<string, string> {
const out: Record<string, string> = {};
/* Default updater-on at booter startup unless explicitly disabled on host. */
const updaterBoot = process.env.BARE_OS_PEAR_UPDATER_BOOT?.trim().toLowerCase();
if (updaterBoot === "0" || updaterBoot === "false" || updaterBoot === "no" || updaterBoot === "off") {
out.BARE_OS_PEAR_UPDATER_BOOT = "0";
} else {
out.BARE_OS_PEAR_UPDATER_BOOT = "1";
}
const keys = [
"BARE_OS_PEAR_CHANNEL",
"BARE_OS_PEAR_RELEASE",
"PEAR_CHANNEL",
"BARE_OS_PEAR_UPDATER_CHANNEL",
"BARE_OS_PEAR_UPDATER_MODULE",
"BARE_OS_PEAR_UPDATER_DELEGATE",
"BARE_OS_PEAR_UPDATER_SNAPSHOT_JSON",
"BARE_OS_PEAR_UPDATER_AUDIT",
];
for (const k of keys) {
const v = process.env[k]?.trim();
if (v) out[k] = v;
}
return out;
}
function pearBinDir(): string {
return path.join(pearHomeDir(), ".config/pear/bin");
}
/**
* Session layout: do **not** put `…/corestores/<id>/session-home/.config/pear/bin` on `PATH` by default — Pear's
* installer echoes `PATH`, which confused operators. Isolation uses `HOME` (session-home); shared shim dir + OS
* paths are enough for the CLI BareCloud launches via `BARECLOUD_PEAR_BIN` / `which pear`. Opt-in:
* `BARECLOUD_PEAR_PATH_SESSION_BIN=1`.
* 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.
*/
function pearPathSessionHome(sessionHome: string, basePath: string): string {
const sessionBin = path.join(sessionHome, ".config/pear/bin");
@@ -128,9 +238,22 @@ function pearManagedEnvSessionHome(booterId: string, termEnv: string): Record<st
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 });
const { basePath, sslCert, nodeExtraCa } = hostPathEnv();
return {
HOME: sessionHome,
/* Prevent inherited global PEAR_HOME from tmux/server environment. */
PEAR_HOME: sessionHome,
/*
* tmux sessions can inherit server env; explicitly pin XDG roots per booter so Pear never falls back
* to host-global config/data/cache paths.
*/
XDG_CONFIG_HOME: path.join(sessionHome, ".config"),
XDG_DATA_HOME: path.join(sessionHome, ".local/share"),
XDG_CACHE_HOME: path.join(sessionHome, ".cache"),
XDG_STATE_HOME: path.join(sessionHome, ".local/state"),
USER: process.env.USER?.trim() || "pear",
LOGNAME: process.env.LOGNAME?.trim() || process.env.USER?.trim() || "pear",
LANG: process.env.LANG?.trim() || "C.UTF-8",
@@ -142,6 +265,7 @@ function pearManagedEnvSessionHome(booterId: string, termEnv: string): Record<st
SSL_CERT_FILE: sslCert,
NODE_EXTRA_CA_CERTS: nodeExtraCa,
TERM: termEnv,
...bareOsPearUpdaterPassthroughEnv(),
};
}
@@ -152,6 +276,11 @@ function pearManagedEnvLegacy(booterId: string, termEnv: string): Record<string,
const pearBD = pearBinDir();
return {
HOME: home,
PEAR_HOME: home,
XDG_CONFIG_HOME: path.join(home, ".config"),
XDG_DATA_HOME: path.join(home, ".local/share"),
XDG_CACHE_HOME: path.join(home, ".cache"),
XDG_STATE_HOME: path.join(home, ".local/state"),
USER: process.env.USER?.trim() || "pear",
LOGNAME: process.env.LOGNAME?.trim() || process.env.USER?.trim() || "pear",
LANG: process.env.LANG?.trim() || "C.UTF-8",
@@ -165,6 +294,7 @@ function pearManagedEnvLegacy(booterId: string, termEnv: string): Record<string,
SSL_CERT_FILE: sslCert,
NODE_EXTRA_CA_CERTS: nodeExtraCa,
TERM: termEnv,
...bareOsPearUpdaterPassthroughEnv(),
};
}
@@ -236,8 +366,11 @@ 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);
console.info(`[barecloud] booter=${booterId} pear_exec=${pearExec}`);
let inner: { file: string; args: string[]; cwd: string; env: Record<string, string> } = {
file: pearCliExecutable(),
file: pearExec,
args: pearRunArgv(pearBootLinkForBooter(booterId)),
cwd,
env,
+4
View File
@@ -238,6 +238,10 @@ export function scrollTmuxPearSession(booterId: string, direction: "up" | "down"
/** Build `tmux new-session -e VAR=value` pairs from Pears per-booter env (tmux does not use the client `env` for the pane). */
function tmuxNewSessionEnvArgs(booterId: string, termEnv: string): string[] {
const managed = pearManagedEnv(booterId, termEnv);
console.info(
`[barecloud] booter=${booterId} tmux_env HOME=${managed.HOME ?? ""} PEAR_HOME=${managed.PEAR_HOME ?? ""} ` +
`XDG_CONFIG_HOME=${managed.XDG_CONFIG_HOME ?? ""} XDG_DATA_HOME=${managed.XDG_DATA_HOME ?? ""}`,
);
const out: string[] = [];
for (const [k, v] of Object.entries(managed)) {
if (typeof v !== "string" || v === "") continue;