QOL Updates
This commit is contained in:
@@ -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 other’s public endpoints: set `BARECLOUD_NETNS_HAIRPIN=1`, set `BARECLOUD_NETNS_HAIRPIN_PUBLIC_IP` to the host’s 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 provider’s **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 other’s **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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -238,6 +238,10 @@ export function scrollTmuxPearSession(booterId: string, direction: "up" | "down"
|
||||
/** Build `tmux new-session -e VAR=value` pairs from Pear’s 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;
|
||||
|
||||
Reference in New Issue
Block a user