diff --git a/.env.example b/.env.example index 2e5257475..7e4eb645d 100644 --- a/.env.example +++ b/.env.example @@ -45,8 +45,14 @@ JWT_TOKEN= OPENHUMAN_CORE_PORT=7788 # [optional] Default: http://127.0.0.1:7788/rpc OPENHUMAN_CORE_RPC_URL=http://127.0.0.1:7788/rpc -# [optional] Pre-seed the core RPC bearer token (Tauri sets this automatically; -# for standalone CLI use only) +# Core RPC bearer token. Single source of truth for /rpc auth. +# - Tauri desktop: set automatically by the shell — leave blank. +# - Docker / cloud / VPS: REQUIRED. Generate with `openssl rand -hex 32`. +# Same value goes in the desktop's app/.env.local (or paste into the +# first-run picker). See gitbooks/features/cloud-deploy.md. +# - Standalone `openhuman core run` on a workstation: leave blank — the +# core writes ${OPENHUMAN_WORKSPACE:-~/.openhuman}/core.token (0o600). +# Use scripts/print-core-token.sh on the host to inspect the active token. # OPENHUMAN_CORE_TOKEN= # [optional] Run mode: child (default, spawns sidecar) | inprocess OPENHUMAN_CORE_RUN_MODE=child diff --git a/app/src/components/BootCheckGate/BootCheckGate.tsx b/app/src/components/BootCheckGate/BootCheckGate.tsx index c5ff540c8..15ed3c06a 100644 --- a/app/src/components/BootCheckGate/BootCheckGate.tsx +++ b/app/src/components/BootCheckGate/BootCheckGate.tsx @@ -14,7 +14,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { type BootCheckResult, runBootCheck } from '../../lib/bootCheck'; import { bootCheckTransport } from '../../services/bootCheckService'; -import { clearCoreRpcTokenCache, clearCoreRpcUrlCache } from '../../services/coreRpcClient'; +import { + clearCoreRpcTokenCache, + clearCoreRpcUrlCache, + testCoreRpcConnection, +} from '../../services/coreRpcClient'; import { type CoreMode, resetCoreMode, setCoreMode } from '../../store/coreModeSlice'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { @@ -63,12 +67,98 @@ interface PickerProps { onConfirm: (mode: CoreMode) => void; } +type TestStatus = + | { kind: 'idle' } + | { kind: 'testing' } + | { kind: 'ok' } + | { kind: 'auth' } + | { kind: 'unreachable'; reason: string }; + function ModePicker({ onConfirm }: PickerProps) { const [selected, setSelected] = useState<'local' | 'cloud'>('local'); const [cloudUrl, setCloudUrl] = useState(''); const [cloudToken, setCloudToken] = useState(''); const [urlError, setUrlError] = useState(null); const [tokenError, setTokenError] = useState(null); + const [testStatus, setTestStatus] = useState({ kind: 'idle' }); + + /** + * Validate the cloud URL + token inputs against a live core before we + * commit the mode. We hit the public `core.ping` (auth-bypass) to confirm + * reachability, then re-issue the same JSON-RPC envelope with the bearer + * token to confirm `/rpc` accepts it. This catches the two most common + * paste-time mistakes — wrong URL, wrong/missing token — with one click, + * before the user lands on the unreachable result screen. + * + * Tokens are never logged: only `tokenLen` is emitted via the existing + * picker debug line, and any error messages from the network/JSON parse + * paths are passed through verbatim without the bearer value. + */ + const validateInputs = (): { url: string; token: string } | null => { + const trimmedUrl = cloudUrl.trim(); + if (!trimmedUrl) { + setUrlError('Please enter a core URL.'); + return null; + } + try { + const parsed = new URL(trimmedUrl); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + setUrlError('URL must start with http:// or https://'); + return null; + } + } catch { + setUrlError('Please enter a valid URL (e.g. https://core.example.com/rpc)'); + return null; + } + setUrlError(null); + + const trimmedToken = cloudToken.trim(); + if (!trimmedToken) { + setTokenError('Please enter the core auth token.'); + return null; + } + setTokenError(null); + + return { url: trimmedUrl, token: trimmedToken }; + }; + + const handleTestConnection = async () => { + const validated = validateInputs(); + if (!validated) return; + + setTestStatus({ kind: 'testing' }); + log( + '[boot-check] picker — testing cloud connection url=%s tokenLen=%d', + validated.url, + validated.token.length + ); + + try { + const response = await testCoreRpcConnection(validated.url, validated.token); + if (response.status === 401 || response.status === 403) { + log('[boot-check] picker — test failed: auth (status=%d)', response.status); + setTestStatus({ kind: 'auth' }); + return; + } + if (!response.ok) { + log('[boot-check] picker — test failed: HTTP %d', response.status); + setTestStatus({ kind: 'unreachable', reason: `HTTP ${response.status} from /rpc` }); + return; + } + // Drain the body — response.ok with JSON-RPC error is still reachable. + try { + await response.json(); + } catch { + // Non-JSON body is unusual but doesn't disprove reachability. + } + log('[boot-check] picker — test succeeded'); + setTestStatus({ kind: 'ok' }); + } catch (err) { + const reason = err instanceof Error ? err.message : 'Connection failed'; + logError('[boot-check] picker — test errored: %o', err); + setTestStatus({ kind: 'unreachable', reason }); + } + }; const handleContinue = () => { if (selected === 'local') { @@ -77,40 +167,15 @@ function ModePicker({ onConfirm }: PickerProps) { return; } - // Basic URL validation: must be http(s) - const trimmedUrl = cloudUrl.trim(); - if (!trimmedUrl) { - setUrlError('Please enter a core URL.'); - return; - } - try { - const parsed = new URL(trimmedUrl); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - setUrlError('URL must start with http:// or https://'); - return; - } - } catch { - setUrlError('Please enter a valid URL (e.g. https://core.example.com/rpc)'); - return; - } - setUrlError(null); - - // Cloud cores require a bearer token (OPENHUMAN_CORE_TOKEN). Without one - // every /rpc call would 401 and the user would be stuck on the boot - // gate; require it up front rather than failing later. - const trimmedToken = cloudToken.trim(); - if (!trimmedToken) { - setTokenError('Please enter the core auth token.'); - return; - } - setTokenError(null); + const validated = validateInputs(); + if (!validated) return; log( '[boot-check] picker — user selected cloud mode url=%s tokenLen=%d', - trimmedUrl, - trimmedToken.length + validated.url, + validated.token.length ); - onConfirm({ kind: 'cloud', url: trimmedUrl, token: trimmedToken }); + onConfirm({ kind: 'cloud', url: validated.url, token: validated.token }); }; return ( @@ -162,6 +227,7 @@ function ModePicker({ onConfirm }: PickerProps) { onChange={e => { setCloudUrl(e.target.value); setUrlError(null); + setTestStatus({ kind: 'idle' }); }} className="rounded-lg border border-stone-600 bg-stone-800 px-3 py-2 text-sm text-white placeholder-stone-500 focus:border-ocean-500 focus:outline-none" /> @@ -180,6 +246,7 @@ function ModePicker({ onConfirm }: PickerProps) { onChange={e => { setCloudToken(e.target.value); setTokenError(null); + setTestStatus({ kind: 'idle' }); }} className="rounded-lg border border-stone-600 bg-stone-800 px-3 py-2 text-sm text-white placeholder-stone-500 focus:border-ocean-500 focus:outline-none" /> @@ -189,6 +256,31 @@ function ModePicker({ onConfirm }: PickerProps) { Authorization: Bearer … on every RPC.

+ +
+ + {testStatus.kind === 'ok' && ( + + Connected ✓ + + )} + {testStatus.kind === 'auth' && ( + + Auth failed — check the token (got 401/403). + + )} + {testStatus.kind === 'unreachable' && ( + + Unreachable: {testStatus.reason} + + )} +
)} diff --git a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx index 31a97830b..477aea46a 100644 --- a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx +++ b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx @@ -24,10 +24,12 @@ vi.mock('../../../lib/bootCheck', () => ({ runBootCheck: (...args: unknown[]) => mockRunBootCheck(...args), })); +const mockTestCoreRpcConnection = vi.fn(); vi.mock('../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn(), clearCoreRpcUrlCache: vi.fn(), clearCoreRpcTokenCache: vi.fn(), + testCoreRpcConnection: (...args: unknown[]) => mockTestCoreRpcConnection(...args), })); vi.mock('../../../utils/configPersistence', () => ({ @@ -186,6 +188,144 @@ describe('BootCheckGate — picker (unset mode)', () => { }); }); +describe('BootCheckGate — picker test connection', () => { + beforeEach(() => { + mockTestCoreRpcConnection.mockReset(); + }); + + function fillCloudInputs(url = 'https://core.example.com/rpc', token = 'tok-abc') { + fireEvent.click(screen.getByText('Cloud')); + fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), { + target: { value: url }, + }); + fireEvent.change(screen.getByPlaceholderText(/Bearer token/i), { target: { value: token } }); + } + + it('shows Connected on a 200 response', async () => { + mockTestCoreRpcConnection.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ result: { ok: true } }), + } as unknown as Response); + + renderGate(); + fillCloudInputs(); + fireEvent.click(screen.getByRole('button', { name: 'Test connection' })); + + await waitFor(() => { + expect(screen.getByTestId('test-status-ok')).toBeInTheDocument(); + }); + expect(mockTestCoreRpcConnection).toHaveBeenCalledWith( + 'https://core.example.com/rpc', + 'tok-abc' + ); + }); + + it('shows Auth failed on a 401 response', async () => { + mockTestCoreRpcConnection.mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ error: 'unauthorized' }), + } as unknown as Response); + + renderGate(); + fillCloudInputs(); + fireEvent.click(screen.getByRole('button', { name: 'Test connection' })); + + await waitFor(() => { + expect(screen.getByTestId('test-status-auth')).toBeInTheDocument(); + }); + }); + + it('shows Auth failed on a 403 response', async () => { + mockTestCoreRpcConnection.mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({}), + } as unknown as Response); + + renderGate(); + fillCloudInputs(); + fireEvent.click(screen.getByRole('button', { name: 'Test connection' })); + + await waitFor(() => { + expect(screen.getByTestId('test-status-auth')).toBeInTheDocument(); + }); + }); + + it('shows Unreachable when fetch rejects', async () => { + mockTestCoreRpcConnection.mockRejectedValue(new Error('network down')); + + renderGate(); + fillCloudInputs(); + fireEvent.click(screen.getByRole('button', { name: 'Test connection' })); + + await waitFor(() => { + expect(screen.getByTestId('test-status-unreachable')).toBeInTheDocument(); + }); + expect(screen.getByTestId('test-status-unreachable').textContent).toMatch(/network down/); + }); + + it('shows Unreachable on non-2xx non-auth response', async () => { + mockTestCoreRpcConnection.mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({}), + } as unknown as Response); + + renderGate(); + fillCloudInputs(); + fireEvent.click(screen.getByRole('button', { name: 'Test connection' })); + + await waitFor(() => { + expect(screen.getByTestId('test-status-unreachable')).toBeInTheDocument(); + }); + expect(screen.getByTestId('test-status-unreachable').textContent).toMatch(/HTTP 500/); + }); + + it('does not call the test endpoint when URL is missing', () => { + renderGate(); + fireEvent.click(screen.getByText('Cloud')); + fireEvent.click(screen.getByRole('button', { name: 'Test connection' })); + + expect(mockTestCoreRpcConnection).not.toHaveBeenCalled(); + expect(screen.getByText('Please enter a core URL.')).toBeInTheDocument(); + }); + + it('does not call the test endpoint when token is missing', () => { + renderGate(); + fireEvent.click(screen.getByText('Cloud')); + fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), { + target: { value: 'https://core.example.com/rpc' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Test connection' })); + + expect(mockTestCoreRpcConnection).not.toHaveBeenCalled(); + expect(screen.getByText(/Please enter the core auth token/i)).toBeInTheDocument(); + }); + + it('clears a stale ok status when the user edits inputs again', async () => { + mockTestCoreRpcConnection.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}), + } as unknown as Response); + + renderGate(); + fillCloudInputs(); + fireEvent.click(screen.getByRole('button', { name: 'Test connection' })); + await waitFor(() => { + expect(screen.getByTestId('test-status-ok')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByPlaceholderText(/Bearer token/i), { + target: { value: 'tok-def' }, + }); + + expect(screen.queryByTestId('test-status-ok')).not.toBeInTheDocument(); + }); +}); + describe('BootCheckGate — checking state', () => { it('shows checking spinner while boot check is in flight', async () => { // Never resolves during this test diff --git a/gitbooks/features/cloud-deploy.md b/gitbooks/features/cloud-deploy.md index e8cb9f72b..2e2335706 100644 --- a/gitbooks/features/cloud-deploy.md +++ b/gitbooks/features/cloud-deploy.md @@ -27,6 +27,41 @@ in `app/.env.local` and launch. --- +## Single source of truth for the bearer token + +Every `/rpc` call carries `Authorization: Bearer `. The core has two +ways to load that token at startup ([`src/core/auth.rs`](../../src/core/auth.rs)): + +1. **`OPENHUMAN_CORE_TOKEN` environment variable** — pre-seeded by the caller + (Tauri shell, Docker, App Platform, systemd unit, …). The core uses this + value as-is and **never** writes a file. +2. **`{workspace}/core.token` file** — generated by the core on first boot + *only when `OPENHUMAN_CORE_TOKEN` is unset*. Standalone `openhuman core run` + uses this so CLI clients can `cat` the file. + +**Rule of thumb for any remote / dockerized deploy: always set +`OPENHUMAN_CORE_TOKEN`.** Do not rely on `core.token` in a container — +ephemeral filesystems lose it on redeploy, and any client trying to read the +file from outside the container will get a stale or empty value. The two +paths are deliberately mutually exclusive at startup; mixing them is the most +common reason behind "the dashboard gets 401 after I redeployed". + +To check what the *running* core is using, run [`scripts/print-core-token.sh`](../../scripts/print-core-token.sh) +on the host (or inside the container with `docker compose exec`): + +```bash +scripts/print-core-token.sh --where # prints 'env' or 'file:/path' +scripts/print-core-token.sh --redact # first 8 hex chars + '…' (safe for logs) +scripts/print-core-token.sh # full value (pipe straight into a client) +``` + +The desktop app's first-run picker also exposes a **Test connection** button +next to the Core RPC URL + token fields, which fires `core.ping` against the +URL with the typed token and reports `Connected ✓` / `Auth failed` / +`Unreachable` inline before persisting the configuration. + +--- + ## What you need before you start | Setting | Required | Notes | @@ -197,6 +232,35 @@ docker compose up -d docker compose logs -f openhuman-core ``` +### Rotating the bearer token + +`OPENHUMAN_CORE_TOKEN` is the only thing standing between the public internet +and full RPC access. Rotate it on a schedule and after any suspected leak: + +```bash +# 1. Generate a new token and update the server-side .env. +openssl rand -hex 32 > /tmp/new-token +sed -i.bak "s|^OPENHUMAN_CORE_TOKEN=.*|OPENHUMAN_CORE_TOKEN=$(cat /tmp/new-token)|" .env +rm /tmp/new-token .env.bak + +# 2. Restart the container so the new value reaches the core process. +docker compose up -d --force-recreate openhuman-core + +# 3. Confirm the running container is using the new token (redacted). +docker compose exec openhuman-core /bin/sh -c \ + 'echo -n "$OPENHUMAN_CORE_TOKEN" | head -c 8; echo "…"' + +# 4. Update every desktop client (Switch mode → re-paste in the picker, or +# edit OPENHUMAN_CORE_TOKEN in app/.env.local and relaunch). Clients that +# still hold the old token will get HTTP 401 on the next /rpc call — that +# is expected, not a regression. +``` + +For App Platform, do the same in **Settings → App-Level Environment +Variables**: edit the `OPENHUMAN_CORE_TOKEN` secret and let App Platform +redeploy. There is no separate token file to delete; the env var is the only +state. + ### Putting it behind TLS Use Caddy, nginx, or Traefik as a reverse proxy in front of `:7788`. A minimal diff --git a/scripts/print-core-token.sh b/scripts/print-core-token.sh new file mode 100755 index 000000000..3d16ea9ae --- /dev/null +++ b/scripts/print-core-token.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# print-core-token.sh — print the active core RPC bearer token for the +# current deploy mode, so operators don't have to remember which side of the +# Tauri-vs-CLI / Docker-vs-binary split owns the value. +# +# Resolution order (matches src/core/auth.rs::init_rpc_token): +# 1. $OPENHUMAN_CORE_TOKEN if set and non-empty (Tauri / Docker / cloud) +# 2. ${OPENHUMAN_WORKSPACE:-$HOME/.openhuman}/core.token (standalone CLI) +# +# Usage: +# scripts/print-core-token.sh # print full token to stdout +# scripts/print-core-token.sh --redact # print first 8 hex chars + '…' only +# scripts/print-core-token.sh --where # print the source (env|file:path) +# and exit without revealing the value +# +# Exit codes: +# 0 success +# 1 no token configured (neither env nor file) +# 2 file exists but is unreadable / empty +# +# This script is read-only and never logs the token to syslog or to debug +# files. When invoked from CI, prefer --redact + --where so logs stay safe. + +set -euo pipefail + +mode="full" +for arg in "$@"; do + case "$arg" in + --redact) + mode="redact" + ;; + --where) + mode="where" + ;; + -h|--help) + sed -n '2,21p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "print-core-token: unknown argument '$arg'" >&2 + exit 64 + ;; + esac +done + +env_token="${OPENHUMAN_CORE_TOKEN:-}" +workspace_dir="${OPENHUMAN_WORKSPACE:-$HOME/.openhuman}" +file_path="$workspace_dir/core.token" + +source="" # one of: env | file +token="" + +if [ -n "$env_token" ]; then + source="env" + token="$env_token" +elif [ -f "$file_path" ]; then + if [ ! -r "$file_path" ]; then + echo "print-core-token: $file_path exists but is not readable by $USER" >&2 + exit 2 + fi + token="$(tr -d '\n\r' < "$file_path" || true)" + if [ -z "$token" ]; then + echo "print-core-token: $file_path is empty" >&2 + exit 2 + fi + source="file:$file_path" +else + cat >&2 <