Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "arrstack-installer",
"version": "1.1.0",
"version": "1.1.1",
"type": "module",
"bin": {
"arrstack": "src/cli.ts"
Expand All @@ -17,7 +17,8 @@
"@types/bcryptjs": "^3.0.0",
"@types/react": "^19.2.14",
"bun-types": "^1.3.12",
"ink-testing-library": "^4.0.0"
"ink-testing-library": "^4.0.0",
"typescript": "^6.0.3"
},
"dependencies": {
"bcryptjs": "^3.0.3",
Expand Down
21 changes: 21 additions & 0 deletions src/catalog/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,27 @@ services:
extraDevices:
- /dev/net/tun:/dev/net/tun

- id: deunhealth
name: Deunhealth
# Restarts qBittorrent when Docker marks it unhealthy. On a host reboot the
# daemon ignores depends_on ordering, so the VPN-routed qbit can start
# before gluetun and lose its published WebUI port; a plain restart policy
# doesn't help because qbit is up, just orphaned from gluetun's netns.
# deunhealth watches the deunhealth.restart.on.unhealthy label (only qbit
# carries it) and restarts it once gluetun is healthy again. The renderer
# bind-mounts /var/run/docker.sock (ro) so it can talk to the daemon.
description: Auto-restarts qBittorrent if it goes unhealthy after a reboot
category: utility
image: qmcgaw/deunhealth
tag: "latest"
ports: []
configPath: /config
mounts: {}
envVars: {}
dependsOn: []
default: false
requiresAdminAuth: false

- id: dnsmasq
name: dnsmasq
description: LAN-wide DNS so "*.<tld>" resolves to this host on every client
Expand Down
20 changes: 19 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { VERSION } from "./version.js";
import { render } from "ink";
import React from "react";
import { App } from "./ui/App.js";
import { readState } from "./state/store.js";
import { readState, adminTxtGuardMessage } from "./state/store.js";
import { runDoctor } from "./usecase/doctor.js";
import { runUpdate } from "./usecase/update.js";
import { showPassword } from "./usecase/show-password.js";
Expand All @@ -16,6 +16,20 @@ import { exec } from "./lib/exec.js";
import { existsSync } from "node:fs";
import { join } from "node:path";

// A previous `sudo` install can leave admin.txt root-owned; a later non-sudo
// run then can't read it, would generate a NEW admin password, rewrite
// qBittorrent.conf, and lock the user out of the still-running qbit container.
// Stop early with a fix instead of silently rotating. Applies to every
// entrypoint that launches the wizard against an existing install (both
// `arrstack install` and bare `arrstack`).
function guardAdminTxtReadable(installDir: string): void {
const msg = adminTxtGuardMessage(installDir);
if (msg) {
console.error(`\n${msg}\n`);
process.exit(1);
}
}

const program = new Command();

program
Expand Down Expand Up @@ -50,6 +64,9 @@ program
console.log(`Removed: ${path}`);
}
}
// --fresh purges the dir (removing admin.txt) just above, so only guard a
// resume/reconfigure over an existing install.
if (!opts.fresh) guardAdminTxtReadable(installDir);
const existing = opts.fresh ? null : readState(installDir);
const { waitUntilExit } = render(React.createElement(App, { existingState: existing }));
await waitUntilExit();
Expand Down Expand Up @@ -129,6 +146,7 @@ program
// If no subcommand provided (just `arrstack`), launch the TUI
program.action(async () => {
const installDir = `${process.env.HOME}/arrstack`;
guardAdminTxtReadable(installDir);
const existing = readState(installDir);
const { waitUntilExit } = render(React.createElement(App, { existingState: existing }));
await waitUntilExit();
Expand Down
21 changes: 21 additions & 0 deletions src/platform/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ export interface CheckResult {
blocking: boolean;
}

// Running the installer as root makes every LinuxServer.io container run as
// root (PUID/PGID 0), which breaks them ("usermod: user abc is currently used
// by process 1") and litters root-owned files. Non-blocking: some users only
// have root, and the wizard now clamps PUID/PGID away from 0 regardless, so
// this is a warning rather than a hard stop.
export function checkNotRoot(): CheckResult {
const uid = typeof process.getuid === "function" ? process.getuid() : -1;
const isRoot = uid === 0;
return {
name: "Not running as root",
ok: !isRoot,
message: isRoot
? "Running as root. LinuxServer.io containers misbehave as root; re-run as a normal user in the 'docker' group (PUID/PGID default to 1000)."
: "Running as a normal user",
blocking: false,
};
}

async function checkDiskSpace(path: string, minGb: number): Promise<CheckResult> {
const result = await exec(["df", "-Pk", path], { timeoutMs: 5000 });
const name = `Disk space on ${path}`;
Expand Down Expand Up @@ -51,6 +69,9 @@ export async function runPreflight(
): Promise<CheckResult[]> {
const results: CheckResult[] = [];

// 0. Not running as root (warning only, non-blocking)
results.push(checkNotRoot());
Comment on lines +72 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve doctor success for root warning

Adding this non-blocking warning to every preflight result makes arrstack doctor fail whenever it is run as root: the doctor loop marks allOk = false for any !check.ok result without checking blocking (src/usecase/doctor.ts:69-76). That turns the new advisory into a failing diagnostic exit even though checkNotRoot() explicitly sets blocking: false; either doctor should treat non-blocking checks as warnings or this check should be skipped there.

Useful? React with 👍 / 👎.


// 1. Docker installed
const dockerInstalled = await isDockerInstalled();
results.push({
Expand Down
90 changes: 89 additions & 1 deletion src/renderer/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ interface ServiceContext {
// forms can't be mixed on one service.
dependsOnLongForm: boolean;
healthcheck?: Healthcheck;
// Compose container labels (e.g. the deunhealth auto-restart marker on qbit).
labels: string[];
vpnNetwork: boolean;
}

Expand Down Expand Up @@ -202,6 +204,65 @@ const GLUETUN_HEALTHCHECK: Healthcheck = {
start_period: "30s",
};

// Label read by the deunhealth sidecar: it restarts any container carrying it
// when Docker reports it unhealthy. We put it on qBittorrent only.
const DEUNHEALTH_LABEL = "deunhealth.restart.on.unhealthy=true";

// Turn a catalog `health` block into a container healthcheck. gluetun keeps its
// bespoke probe. For everything else we only render `http` blocks: the probe is
// curl-OR-wget (every arr/media image ships at least one; e.g. jellyseerr has
// only wget, bazarr only curl) hitting an unauthenticated endpoint, so a real
// 200 is required to count as healthy. `tcp` blocks (caddy, recyclarr) are
// skipped: a bare port-open check needs nc, which many images lack, and caddy's
// :80 can legitimately 404 on an unmatched host. Timings are generous so a slow
// first boot doesn't flap to unhealthy.
function buildHealthcheck(svc: Service): Healthcheck | undefined {
if (svc.id === "gluetun") return GLUETUN_HEALTHCHECK;
const h = svc.health;
if (!h || h.type !== "http" || !h.path || h.port <= 0) return undefined;
const url = `http://127.0.0.1:${h.port}${h.path}`;
return {
test: `["CMD-SHELL", "curl -fsS -o /dev/null ${url} || wget -q -O /dev/null ${url} || exit 1"]`,
Comment on lines +223 to +225

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Probe qBittorrent through gluetun for self-heal

For VPN-routed qBittorrent, this healthcheck runs inside the qBittorrent container and curls its own loopback address. In the reboot/orphaned-netns case described below, qBittorrent can still serve 127.0.0.1:8080 inside its stale namespace while gluetun no longer publishes that port to the host, so Docker keeps the container healthy and deunhealth never restarts it. The probe needs to exercise the gluetun/published path (or otherwise detect the stale namespace) for the new sidecar to fix the reboot failure.

Useful? React with 👍 / 👎.

interval: "30s",
timeout: "10s",
retries: 5,
start_period: "45s",
};
}

// Healthcheck for a VPN-routed service (qBittorrent inside gluetun's netns).
// A loopback WebUI probe is the WRONG signal: after a reboot the container can
// be orphaned from gluetun's netns with its published port dead, yet its own
// 127.0.0.1:8080 still answers, so Docker keeps it `healthy` and deunhealth
// never restarts it. Probe gluetun's control server instead. Because qbit shares
// gluetun's netns it can reach the control server on 127.0.0.1:8000, but ONLY
// while it sits in gluetun's LIVE netns; an orphaned/stale netns has no such
// listener, so the probe fails and deunhealth restarts qbit. Crucially the
// control server is up whenever the gluetun CONTAINER is up, independent of the
// tunnel/WAN, so a real VPN outage does NOT restart-loop qbit (unlike an
// external connectivity check), and there's no third-party dependency. `-f` is
// omitted so an auth 401 still counts as reachable; only a refused connection
// (orphaned netns) fails. curl is used directly (LSIO qbit ships it) since
// busybox wget can't distinguish a 401 from a refused connection.
const GLUETUN_CONTROL_URL = "http://127.0.0.1:8000/v1/publicip/ip";
function buildVpnHealthcheck(webuiPort: number): Healthcheck {
// Healthy requires BOTH legs. Leg 1 (gluetun's control server) proves qbit is
// in gluetun's LIVE netns, catching the reboot orphaned-netns case; no -f, so
// an auth 401 still counts as reachable, and it stays green during a real
// tunnel outage (the container is up). Leg 2 (qbit's own WebUI) catches a
// hung/crashed qbit while gluetun is up, which leg 1 alone would miss; -f so a
// non-2xx or timed-out UI fails. Either leg failing -> unhealthy -> deunhealth
// restarts qbit.
const webui = `http://127.0.0.1:${webuiPort}/`;
return {
test: `["CMD-SHELL", "curl -sS -m 10 -o /dev/null ${GLUETUN_CONTROL_URL} && curl -fsS -m 10 -o /dev/null ${webui} || exit 1"]`,
interval: "30s",
timeout: "10s",
retries: 3,
start_period: "40s",
};
}

// Translates the wizard's VPN state into the env vars gluetun expects
// (VPN_SERVICE_PROVIDER / VPN_TYPE / WIREGUARD_* / SERVER_COUNTRIES / custom
// endpoint tuple). Only emitted for the gluetun service and only when VPN
Expand Down Expand Up @@ -265,11 +326,37 @@ export function buildComposeContext(services: Service[], opts: ComposeOptions):
}
const dependsOnLongForm = dependsOn.some((d) => d.condition !== undefined);

const healthcheck = svc.id === "gluetun" ? GLUETUN_HEALTHCHECK : undefined;
// VPN-routed services need a tunnel-connectivity probe (see buildVpnHealthcheck)
// so deunhealth can actually detect the reboot orphaned-netns state; a plain
// loopback probe would pass even when the published port is dead.
const healthcheck = vpnNetwork
? buildVpnHealthcheck(svc.health?.port ?? 8080)
: buildHealthcheck(svc);

// Only qBittorrent is auto-restarted by deunhealth. On a host reboot Docker
// ignores depends_on ordering, so qbit (network_mode: service:gluetun) can
// start before gluetun and end up orphaned from its netns with its WebUI
// port dead. restart: unless-stopped won't help because qbit is up, just
// broken. Its healthcheck flips it to unhealthy and deunhealth restarts it
// once gluetun is ready. Labeling only qbit means nothing else is touched.
const labels: string[] = [];
if (vpnNetwork) {
labels.push(DEUNHEALTH_LABEL);
}

const caddyImage = resolveCaddyImage(svc, opts.remoteMode);

const dataMounts = buildDataMounts(svc, opts.storageRoot, opts.extraPaths);
if (svc.id === "deunhealth") {
// deunhealth watches Docker's health status and restarts labeled
// containers, so it needs the daemon socket. Read-only is enough: the
// restart API call still works over a ro-mounted socket.
dataMounts.push({
src: "/var/run/docker.sock",
dst: "/var/run/docker.sock",
mode: "ro",
});
}
if (svc.id === "caddy") {
// The renderer writes {installDir}/Caddyfile to disk, but the container
// needs it at /etc/caddy/Caddyfile. Without this explicit mount Caddy
Expand Down Expand Up @@ -299,6 +386,7 @@ export function buildComposeContext(services: Service[], opts: ComposeOptions):
dependsOn,
dependsOnLongForm,
healthcheck,
labels,
vpnNetwork,
};
});
Expand Down
12 changes: 12 additions & 0 deletions src/state/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ export const StateSchema = z.object({
// from this list. English-only is the safest out-of-the-box default.
subtitle_languages: z.array(z.string().length(2)).default(["en"]),
api_keys: z.record(z.string(), z.string()),
// Long-lived generated secrets that are NOT service API keys but must still
// survive reconfigure/resume. Rotating these breaks a running stack: the
// AES-GCM key is shared by Bazarr and ai-subtitle-translator (rotating it
// orphans everything they encrypted at rest), and the Flask secret would
// invalidate all Bazarr sessions. Optional/defaulted so old state.json files
// (written before this field existed) still parse.
secrets: z
.object({
translator_encryption_key: z.string().optional(),
bazarr_flask_secret: z.string().optional(),
})
.default({}),
install_started_at: z.string().datetime().optional(),
install_completed_at: z.string().datetime().optional(),
last_updated_at: z.string().datetime().optional(),
Expand Down
34 changes: 34 additions & 0 deletions src/state/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,40 @@ export function readState(installDir: string): State | null {
return StateSchema.parse(data);
}

/**
* True when admin.txt exists but cannot be read. This happens when a prior
* `sudo arrstack install` left it owned by root and a later non-sudo run hits
* EACCES. The installer must NOT treat that as "no password" and silently
* generate a new one: the qBittorrent container from the earlier run still
* holds the old password, so a rotate rewrites qBittorrent.conf with a hash the
* running container never sees and the user is locked out. Callers should stop
* with an actionable message instead. A genuinely absent file returns false
* (that is a normal fresh install).
*/
export function adminTxtUnreadable(installDir: string): boolean {
const p = join(installDir, "admin.txt");
if (!existsSync(p)) return false;
try {
readFileSync(p, "utf-8");
return false;
} catch {
return true;
}
}

// The actionable message to print when admin.txt can't be read, or null when
// it's fine to proceed. Kept here (not in cli.ts, which self-executes on import)
// so the guard's decision is unit-testable. cli.ts prints this and exits.
export function adminTxtGuardMessage(installDir: string): string | null {
if (!adminTxtUnreadable(installDir)) return null;
const p = join(installDir, "admin.txt");
return (
`Cannot read ${p}. It looks like a previous 'sudo arrstack install' left it owned by root.\n` +
`Fix ownership and re-run as your normal user (not sudo):\n` +
` sudo chown $(id -u):$(id -g) ${p}`
);
}

export function writeState(installDir: string, state: State): void {
const statePath = join(installDir, STATE_FILE);
const tmpPath = `${statePath}.tmp`;
Expand Down
1 change: 1 addition & 0 deletions src/ui/wizard/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ export function Form({ initial, isReconfigure, onSubmit, onCancel }: FormProps)
diskInfo={ws.diskInfo}
dockerOk={ws.dockerOk}
portsOk={ws.portsOk}
isRoot={ws.isRoot}
gpuName={ws.detectedGpus.find((g) => g.vendor === ws.gpuVendor)?.name}
caddyHttpPort={ws.caddyHttpPort}
caddyHttpsPort={ws.caddyHttpsPort}
Expand Down
8 changes: 7 additions & 1 deletion src/ui/wizard/StatusStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ interface StatusStripProps {
diskInfo: Array<{ path: string; freeGb: number }>;
dockerOk: boolean;
portsOk: boolean;
isRoot?: boolean;
gpuName?: string;
caddyHttpPort?: number;
caddyHttpsPort?: number;
portConflicts?: string[];
}

export function StatusStrip({ diskInfo, dockerOk, portsOk, gpuName, caddyHttpPort, caddyHttpsPort, portConflicts }: StatusStripProps) {
export function StatusStrip({ diskInfo, dockerOk, portsOk, isRoot, gpuName, caddyHttpPort, caddyHttpsPort, portConflicts }: StatusStripProps) {
const caddyNote = (!portsOk && caddyHttpPort && caddyHttpsPort)
? `Caddy:${caddyHttpPort}/${caddyHttpsPort}`
: portsOk ? "80/443:free" : "80/443:in use";
Expand All @@ -31,6 +32,11 @@ export function StatusStrip({ diskInfo, dockerOk, portsOk, gpuName, caddyHttpPor
{portConflicts && portConflicts.length > 0 && (
<Text color="yellow">Port remaps: {portConflicts.join(", ")}</Text>
)}
{isRoot && (
<Text color="yellow">
⚠ Running as root: containers will use PUID/PGID 1000, not 0. Prefer running as a normal user in the 'docker' group.
</Text>
)}
</Box>
);
}
Loading
Loading