Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f50e743
chore(docker): add compose deployment
lukyrys May 4, 2026
ac59578
fix(frontend): proxy websocket in local dev
lukyrys May 4, 2026
5c07d2e
fix(storage): fail fast on EACCES/EROFS/EPERM/ENOSPC/EISDIR with oper…
lukyrys May 9, 2026
7e9bc0a
feat(api): add /health liveness endpoint
lukyrys May 9, 2026
e08d4f4
feat(app): add --healthcheck self-flag for orchestrator probes
lukyrys May 9, 2026
381ccba
fix(docker): proxy websocket reconnect with bounded backlog and re-ha…
lukyrys May 9, 2026
60b9c1a
chore(docker): align with mainline embed-worker and gate frontend on …
lukyrys May 9, 2026
5e13f11
chore(dockerignore): tighten exclusions for build context size and se…
lukyrys May 9, 2026
0c2fe70
test(api,storage,healthcheck): cover liveness probe, fail-fast classi…
lukyrys May 9, 2026
ad4f355
docs(docker): document first-run perms, healthcheck, and ws-proxy res…
lukyrys May 9, 2026
aabd5ca
feat(docker): bind backend port to localhost by default and pass LISH…
lukyrys May 9, 2026
4f4fe09
docs(docker): document BACKEND_BIND and LISH_TOKEN authentication
lukyrys May 9, 2026
8e102da
fix(docker): forward client query string on proxy upstream for auth t…
lukyrys May 9, 2026
c712077
fix(app): probe both IPv4 and IPv6 loopback in --healthcheck self-flag
lukyrys May 9, 2026
728b6cd
feat(docker): self-repair bind-mount ownership at container startup
lukyrys May 10, 2026
94cf7bf
docs(docker): drop manual chown step from first-run instructions
lukyrys May 10, 2026
aaae1c3
feat(docker): allow chown of 0700 host bind dirs via CAP_DAC_READ_SEARCH
lukyrys May 10, 2026
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
35 changes: 35 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
**/node_modules/
**/build/
**/.svelte-kit/
**/.vite-cache/
frontend/build/
frontend/tests/
backend/data/
backend/tests/
data/
app/
cli/
docker/config/
docker/storage/
docker/certs/
*.log
*.stackdump
*.bun-build
**/*.lish
**/*.lishnet
**/settings.json
**/peer-id.json
**/datastore/
.git/
.github/
.vscode/
.idea/
**/.env
**/.env.*
**/*.local
**/*.local.json
**/*.local.neon
**/*.local.yaml
**/*.local.toml
coverage/
target/
18 changes: 18 additions & 0 deletions backend/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ export interface APIServerOptions {
apiToken?: string | undefined;
}

/**
* Liveness probe handler used by the docker-compose healthcheck and external
* orchestrators. Returns a 200 plain-text response when the URL pathname is
* exactly `/health`, or `null` to let the caller fall through to other
* routing (WebSocket upgrade, 400 fallback). Pure so it stays unit-testable
* without spinning up the full APIServer dependency graph.
*/
export function handleHealthProbe(req: globalThis.Request): Response | null {
const url = new URL(req.url);
if (url.pathname === '/health') return new Response('ok\n', { status: 200, headers: { 'content-type': 'text/plain' } });
return null;
}

export class APIServer {
private clients: Set<ClientSocket> = new Set();
private server: ReturnType<typeof Bun.serve<ClientData>> | null = null;
Expand Down Expand Up @@ -201,6 +214,11 @@ export class APIServer {
hostname: this.host,
fetch(req, server): Response | undefined {
const url = new URL(req.url);
// Liveness probe used by docker-compose healthcheck and external
// orchestrators. Placed before auth + per-request log so probes
// don't need a token and don't pollute traces at probe cadence.
const probe = handleHealthProbe(req);
if (probe) return probe;
console.log(`[API] Incoming request: ${req.method} ${url.pathname}`);
if (req.method === 'OPTIONS' && url.pathname === '/status') return self.statusOptionsResponse();
if (url.pathname === '/status') return self.statusResponse(url);
Expand Down
25 changes: 25 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { dirname, join } from 'path';
import { productName, productVersion } from '@shared';
import { resolveHealthcheckPort } from './healthcheck.ts';
import { setupLogger, type LogLevel } from './logger.ts';
import { Networks } from './lishnet/lishnets.ts';
import { DataServer } from './lish/data-server.ts';
Expand Down Expand Up @@ -53,6 +54,30 @@ for (let i = 0; i < args.length; i++) {
}
}

// Self-healthcheck mode used by docker-compose / orchestrators. Performs a
// single HTTP GET against the running instance's `/health` endpoint and exits
// 0 on 2xx, 1 otherwise — no logger setup, no DB open, no libp2p init.
if (args.includes('--healthcheck')) {
const decision = resolveHealthcheckPort(apiPort, process.env['BACKEND_PORT']);
if (decision.exit !== undefined) {
if (decision.message) console.error(decision.message);
process.exit(decision.exit);
}
// Try IPv4 first, then IPv6 — `--host localhost` on Windows binds only to
// `[::1]` while the same flag in a Docker container binds to `127.0.0.1`.
// Probing both addresses keeps the self-flag portable across deployments.
const targets = [`http://127.0.0.1:${decision.port}/health`, `http://[::1]:${decision.port}/health`];
for (const target of targets) {
try {
const res = await fetch(target, { signal: AbortSignal.timeout(2500) });
if (res.ok) process.exit(0);
} catch {
// Try the next address.
}
}
process.exit(1);
}

setupLogger(logLevel, logFile ?? join(dataDir, 'libershare.log'));
const header = `${productName} v${productVersion}`;
console.log('='.repeat(header.length));
Expand Down
43 changes: 43 additions & 0 deletions backend/src/healthcheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Decision returned by {@link resolveHealthcheckPort}. The probe should
* exit immediately with `exit` if it is set; otherwise `port` carries the
* resolved target for the localhost HTTP probe.
*/
export interface HealthcheckPortDecision {
port: number;
exit?: number;
message?: string;
}

/**
* Resolve the port the `--healthcheck` self-flag should probe.
*
* Priority:
* 1. Explicit `--port` argument (already parsed into a number by app.ts).
* 2. `BACKEND_PORT` environment variable, if it parses as a positive integer.
* 3. Fallback to 1158 — the binary's own default elsewhere.
*
* A `--port 0` (random-port) configuration without a `BACKEND_PORT` env var
* cannot be probed from a separate process: there is no way to discover the
* actual bound port without intervening file/IPC. Returning a non-zero exit
* code instead of guessing 1158 surfaces the misconfiguration to the
* orchestrator instead of silently flapping the container.
*/
export function resolveHealthcheckPort(apiPort: number, backendPortEnv: string | undefined): HealthcheckPortDecision {
if (apiPort > 0) return { port: apiPort };
if (backendPortEnv !== undefined && backendPortEnv.length > 0) {
const envPort = Number(backendPortEnv);
if (Number.isFinite(envPort) && envPort > 0) return { port: envPort };
// User explicitly set BACKEND_PORT but it didn't parse — surface that
// rather than silently falling back to a default port the operator
// might not be expecting.
return {
port: 0,
exit: 2,
message: `[Healthcheck] BACKEND_PORT="${backendPortEnv}" is not a positive integer; cannot probe`,
};
}
// No explicit configuration — fall back to the binary's documented
// default. Caller is presumed to also be running the server on 1158.
return { port: 1158 };
}
18 changes: 13 additions & 5 deletions backend/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ export interface SettingsData {
};
}

function storagePath(envName: string, defaultRelative: string, fallback: string): string {
const explicit = process.env[envName];
if (explicit) return explicit;
const root = process.env['LIBERSHARE_STORAGE_ROOT'];
if (!root) return fallback;
return `${root.replace(/[\\/]+$/, '')}/${defaultRelative}/`;
}

const DEFAULT_SETTINGS: SettingsData = {
language: '',
ui: {
Expand Down Expand Up @@ -147,11 +155,11 @@ const DEFAULT_SETTINGS: SettingsData = {
volume: 50,
},
storage: {
downloadPath: '~/LiberShare/finished/',
tempPath: '~/LiberShare/temp/',
lishPath: '~/LiberShare/lish/',
lishnetPath: '~/LiberShare/lishnet/',
backupPath: '~/LiberShare/backup/',
downloadPath: storagePath('LIBERSHARE_DOWNLOAD_PATH', 'finished', '~/LiberShare/finished/'),
tempPath: storagePath('LIBERSHARE_TEMP_PATH', 'temp', '~/LiberShare/temp/'),
lishPath: storagePath('LIBERSHARE_LISH_PATH', 'lish', '~/LiberShare/lish/'),
lishnetPath: storagePath('LIBERSHARE_LISHNET_PATH', 'lishnet', '~/LiberShare/lishnet/'),
backupPath: storagePath('LIBERSHARE_BACKUP_PATH', 'backup', '~/LiberShare/backup/'),
},
network: {
incomingPort: 9090,
Expand Down
44 changes: 44 additions & 0 deletions backend/src/storage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
import { join } from 'path';

/**
* Filesystem error codes that signal the data directory cannot be written —
* persisting state would silently disappear, so the caller fails fast instead
* of limping along with non-persistent in-memory state. Exported so unit tests
* can drive the same set the production code uses.
*/
export const FATAL_STORAGE_CODES = ['EACCES', 'EROFS', 'EPERM', 'ENOSPC', 'EISDIR'] as const;
export type FatalStorageCode = (typeof FATAL_STORAGE_CODES)[number];

export function isFatalStorageError(error: unknown): error is NodeJS.ErrnoException & { code: FatalStorageCode } {
const code = (error as NodeJS.ErrnoException | null)?.code;
return typeof code === 'string' && (FATAL_STORAGE_CODES as readonly string[]).includes(code);
}

/**
* Build the operator-facing message for a fatal storage error. Pure function
* so unit tests can assert the exact wording without spawning a real process.
*/
export function fatalStorageMessage(filePath: string, code: FatalStorageCode): string[] {
const lines = [`[Storage] FATAL: cannot persist ${filePath} (${code}).`];
if (code === 'ENOSPC') {
lines.push(`[Storage] The filesystem hosting the data directory is full.`);
} else if (code === 'EISDIR') {
lines.push(`[Storage] A directory exists where a file is expected — remove it before restart.`);
} else {
lines.push(`[Storage] If running in Docker with cap_drop:ALL, the container loses CAP_DAC_OVERRIDE and`);
lines.push(`[Storage] cannot write to a host bind-mount unless its owner matches the container UID.`);
lines.push(`[Storage] Fix on the host: chown 0:0 <mounted-dir> && chmod 0700 <mounted-dir>, then restart.`);
}
return lines;
}

/**
* Base class for JSON file storage.
*/
Expand Down Expand Up @@ -30,6 +62,18 @@ abstract class BaseStorage<T> {
try {
await Bun.write(this.filePath, JSON.stringify(data, null, '\t'));
} catch (error) {
// Permission / read-only filesystem errors at this layer mean every
// subsequent write to settings.json (peer identity, joined networks,
// user preferences) would silently disappear and the next restart
// would regenerate state from defaults. That is much worse than
// crashing — fail fast with an operator-actionable hint instead of
// limping along. The most common trigger in container deployments is
// `cap_drop: ALL` stripping CAP_DAC_OVERRIDE while the bind-mount on
// the host is owned by a non-root user.
if (isFatalStorageError(error)) {
for (const line of fatalStorageMessage(this.filePath, error.code!)) console.error(line);
process.exit(74); // sysexits.h EX_IOERR
}
console.error(`[Storage] Error saving ${this.filePath}:`, error);
}
}
Expand Down
32 changes: 32 additions & 0 deletions backend/tests/unit/api/health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'bun:test';
import { handleHealthProbe } from '../../../src/api/api.ts';

describe('handleHealthProbe', () => {
it('returns 200 with plain text body for /health', async () => {
const res = handleHealthProbe(new Request('http://localhost:1158/health'));
expect(res).not.toBeNull();
expect(res!.status).toBe(200);
expect(res!.headers.get('content-type')).toContain('text/plain');
expect(await res!.text()).toBe('ok\n');
});

it('returns null for the WebSocket-upgrade path', () => {
expect(handleHealthProbe(new Request('http://localhost:1158/'))).toBeNull();
});

it('returns null for arbitrary paths', () => {
expect(handleHealthProbe(new Request('http://localhost:1158/api/method'))).toBeNull();
expect(handleHealthProbe(new Request('http://localhost:1158/healthcheck'))).toBeNull();
expect(handleHealthProbe(new Request('http://localhost:1158/HEALTH'))).toBeNull();
});

it('matches exact path even with query string', async () => {
const res = handleHealthProbe(new Request('http://localhost:1158/health?probe=1'));
expect(res).not.toBeNull();
expect(res!.status).toBe(200);
});

it('does not match nested paths', () => {
expect(handleHealthProbe(new Request('http://localhost:1158/health/extra'))).toBeNull();
});
});
50 changes: 50 additions & 0 deletions backend/tests/unit/healthcheck.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'bun:test';
import { resolveHealthcheckPort } from '../../src/healthcheck.ts';

describe('resolveHealthcheckPort', () => {
it('uses explicit --port when positive', () => {
expect(resolveHealthcheckPort(2200, undefined)).toEqual({ port: 2200 });
});

it('explicit --port wins over BACKEND_PORT env', () => {
expect(resolveHealthcheckPort(2200, '9999')).toEqual({ port: 2200 });
});

it('falls back to BACKEND_PORT when --port is unset', () => {
const decision = resolveHealthcheckPort(0, '2200');
expect(decision.port).toBe(2200);
expect(decision.exit).toBeUndefined();
});

it('falls back to 1158 when neither --port nor BACKEND_PORT is set', () => {
expect(resolveHealthcheckPort(0, undefined)).toEqual({ port: 1158 });
});

it('falls back to 1158 when BACKEND_PORT is empty string', () => {
expect(resolveHealthcheckPort(0, '')).toEqual({ port: 1158 });
});

it('exits with code 2 when BACKEND_PORT is non-numeric', () => {
const decision = resolveHealthcheckPort(0, 'abc');
expect(decision.exit).toBe(2);
expect(decision.message).toContain('BACKEND_PORT');
expect(decision.message).toContain('"abc"');
});

it('exits with code 2 when BACKEND_PORT is zero', () => {
const decision = resolveHealthcheckPort(0, '0');
expect(decision.exit).toBe(2);
});

it('exits with code 2 when BACKEND_PORT is negative', () => {
const decision = resolveHealthcheckPort(0, '-1');
expect(decision.exit).toBe(2);
});

it('returns the parsed port even when --port=0', () => {
// Random-port mode (--port 0) is allowed if the operator wires
// BACKEND_PORT to point at the actual bound port.
const decision = resolveHealthcheckPort(0, '54321');
expect(decision.port).toBe(54321);
});
});
56 changes: 56 additions & 0 deletions backend/tests/unit/storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'bun:test';
import { isFatalStorageError, fatalStorageMessage, FATAL_STORAGE_CODES } from '../../src/storage.ts';

describe('storage fatal-error classifier', () => {
for (const code of FATAL_STORAGE_CODES) {
it(`classifies ${code} as fatal`, () => {
const err = Object.assign(new Error('boom'), { code });
expect(isFatalStorageError(err)).toBe(true);
});
}

it('does not classify ENOENT as fatal', () => {
const err = Object.assign(new Error('not found'), { code: 'ENOENT' });
expect(isFatalStorageError(err)).toBe(false);
});

it('does not classify a plain Error as fatal', () => {
expect(isFatalStorageError(new Error('plain'))).toBe(false);
});

it('does not classify null/undefined as fatal', () => {
expect(isFatalStorageError(null)).toBe(false);
expect(isFatalStorageError(undefined)).toBe(false);
});
});

describe('storage fatal-error message', () => {
const fixture = '/app/config/settings.json';

it('mentions the file path and code on every line block', () => {
const lines = fatalStorageMessage(fixture, 'EACCES');
expect(lines[0]).toContain(fixture);
expect(lines[0]).toContain('EACCES');
expect(lines.length).toBeGreaterThan(1);
});

it('includes the chown remediation hint for permission codes', () => {
for (const code of ['EACCES', 'EROFS', 'EPERM'] as const) {
const joined = fatalStorageMessage(fixture, code).join('\n');
expect(joined).toContain('chown 0:0');
expect(joined).toContain('cap_drop');
}
});

it('uses a disk-full hint for ENOSPC instead of the chown hint', () => {
const joined = fatalStorageMessage(fixture, 'ENOSPC').join('\n');
expect(joined).toContain('full');
expect(joined).not.toContain('chown 0:0');
});

it('uses a directory-clash hint for EISDIR', () => {
const joined = fatalStorageMessage(fixture, 'EISDIR').join('\n');
expect(joined).toContain('directory');
expect(joined).not.toContain('chown 0:0');
});
});
Loading