feat(cli): make connect-url --relay a full anywhere pairing link

- --relay links now carry both routes: direct LAN plus relay fallback,
  matching the UI's Anywhere pairing; devices prefer the direct route
- pairing sessions created by the CLI are marked with usesRelay, and the
  server reconciles relay demand on a timer, so a headless instance
  brings the relay up on its own after connect-url --relay
- warn with LAN_UNREACHABLE when the link's direct route points at
  loopback and other devices cannot use it
- document the --relay flow and the --lan binding caveat in Connect a
  Device and Remote Instances across all locales
This commit is contained in:
Bohdan Triapitsyn
2026-07-10 18:29:15 +03:00
parent de92b8fef4
commit 6ec1797583
21 changed files with 182 additions and 87 deletions
+6 -5
View File
@@ -387,7 +387,7 @@ OPTIONS:
--hostname Alias for --host outside tunnel commands
--lan Bind to 0.0.0.0 for LAN access
--server <url> Public/server URL for connect-url links
--relay connect-url: generate an end-to-end-encrypted relay pairing link
--relay connect-url: also include the end-to-end-encrypted relay transport
--ui-password Protect browser UI with single password
--api-only Start API routes only, without serving browser UI assets
--foreground Run server in foreground (use with systemd/process managers)
@@ -465,10 +465,11 @@ OPTIONS:
--lan Bind to 0.0.0.0 for LAN access when starting
--server <url> Public URL saved into the connection link
--server-url <url> Alias for --server
--relay Generate an end-to-end-encrypted relay pairing link
(no server URL needed; requires the relay enabled on
this instance). Set OPENCHAMBER_RELAY_URL to use a
self-hosted relay.
--relay Also include the end-to-end-encrypted relay transport
so the link works away from the local network. The
device prefers the direct connection when reachable;
the instance brings the relay up on its own. Set
OPENCHAMBER_RELAY_URL to use a self-hosted relay.
--name <label> Label saved with the remote client token
--ui-password <value> Protect browser access when UI routes are enabled
--api-only Start in headless/API-only mode when starting
+32 -56
View File
@@ -137,50 +137,6 @@ function buildPairingPayload({ pairing, label, candidates }) {
};
}
// Relay-only pairing link: the sole candidate is the relay transport, for
// sharing with a device that is not on the host's network. Needs no reachable
// server URL, but the host must be running with the relay enabled to serve the
// redeem over the tunnel.
async function generateRelayConnectUrl(options) {
const label = options.name || os.hostname();
const relay = await buildRelayPairingCandidate();
const pairingRuntime = createCliPairingRuntime();
const { pairing } = await pairingRuntime.createPairingSession({ label });
const connectUrl = encodePairingConnectUrl(buildPairingPayload({ pairing, label, candidates: [relay.candidate] }));
if (isJsonMode(options)) {
printJson({
mode: 'relay',
relayUrl: relay.relayUrl,
serverId: relay.serverId,
relayEnabled: relay.enabled,
pairingId: pairing.id,
fingerprint: pairing.fingerprint,
expiresAt: pairing.expiresAt,
connectUrl,
});
return;
}
if (isQuietMode(options)) {
process.stdout.write(`${connectUrl}\n`);
return;
}
clackIntro('OpenChamber relay pairing link');
logStatus('success', connectUrl);
clackLog.info(`Relay: ${relay.relayUrl}`);
if (pairing.fingerprint) clackLog.info(`Fingerprint: ${pairing.fingerprint}`);
if (!relay.enabled) {
logStatus('info', '[RELAY_ENABLE]', 'Enable the relay on this instance so this link can connect (Settings -> Remote Instances).');
}
clackLog.info('Scan or paste this link into another OpenChamber client. It is single-use and expires.');
if (options.qr === true) {
await displayTunnelQrCode(connectUrl);
}
clackOutro('relay pairing link generated');
}
async function resolveConnectUrlServerUrl(options) {
let hostOverride = options.host;
if (typeof hostOverride !== 'string' && !process.env.OPENCHAMBER_HOST) {
@@ -226,6 +182,15 @@ function isWildcardBindHost(host) {
return host === '0.0.0.0' || host === '::' || host === '[::]';
}
function isLoopbackServerUrl(serverUrl) {
try {
const hostname = new URL(serverUrl).hostname.replace(/^\[|\]$/g, '');
return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1';
} catch {
return false;
}
}
function normalizeServerUrlForConnection(value) {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (!trimmed) return null;
@@ -266,13 +231,6 @@ function createConnectUrlCommand({ serveCommand }) {
throw new TunnelCliError('Invalid --server URL. Use an http:// or https:// URL.', EXIT_CODE.USAGE_ERROR);
}
// Relay pairing needs neither a reachable server URL nor a running server:
// the link is built from the instance's local relay identity + a fresh client
// token. The client reads the relay endpoint from the offer.
if (options.relay) {
return await generateRelayConnectUrl(options);
}
const running = await discoverRunningInstances();
const serverState = running.some((entry) => entry.port === options.port)
? { port: options.port, autoStarted: false }
@@ -298,14 +256,20 @@ function createConnectUrlCommand({ serveCommand }) {
const label = options.name || os.hostname();
// Direct candidate for the reachable server URL, plus the relay transport as
// a fallback candidate when the host relay is enabled — one link that works
// both on the LAN and off-network.
// a fallback candidate — one link that works both on the LAN and off-network.
// Candidate priorities make the client prefer the direct route and try the
// relay last, mirroring the UI's "Anywhere" pairing. `--relay` opts in even
// when the host relay is not up yet (the demand-driven lifecycle starts it);
// otherwise the relay rides along only when it is already enabled.
const candidates = [{ type: serverUrl.startsWith('https://') ? 'tunnel' : 'lan', url: serverUrl, priority: 10 }];
const relay = await buildRelayPairingCandidate();
if (relay.enabled) candidates.push(relay.candidate);
if (options.relay || relay.enabled) candidates.push(relay.candidate);
const pairingRuntime = createCliPairingRuntime();
const { pairing } = await pairingRuntime.createPairingSession({ label });
// Mark relay-carrying sessions like the server route does, so the host's
// demand-driven relay lifecycle keeps the relay up while the link is pending.
const usesRelay = candidates.some((candidate) => candidate.type === 'relay');
const { pairing } = await pairingRuntime.createPairingSession({ label, usesRelay });
const connectUrl = encodePairingConnectUrl(buildPairingPayload({ pairing, label, candidates }));
if (isJsonMode(options)) {
@@ -332,9 +296,12 @@ function createConnectUrlCommand({ serveCommand }) {
}
logStatus('success', connectUrl);
clackLog.info(`Server URL: ${serverUrl}`);
if (relay.enabled) {
if (options.relay || relay.enabled) {
clackLog.info(`Relay fallback: ${relay.relayUrl}`);
}
if (options.relay && !relay.enabled) {
logStatus('info', '[RELAY_STARTING]', 'Relay is not up yet. A running instance starts it within a minute; a stopped instance starts it on next launch.');
}
if (pairing.fingerprint) {
clackLog.info(`Fingerprint: ${pairing.fingerprint}`);
}
@@ -342,6 +309,15 @@ function createConnectUrlCommand({ serveCommand }) {
clackLog.info('Detected a LAN address because OpenChamber is bound to all interfaces. Use --server to override it.');
} else if (resolvedServerUrl.source === 'loopback-fallback') {
clackLog.warn('OpenChamber is bound to all interfaces, but no LAN address was detected. Use --server to provide a reachable URL.');
} else if (isLoopbackServerUrl(serverUrl)) {
// The direct candidate points at this machine only — other devices cannot
// use it. Say so instead of letting a "LAN" link silently not work (or a
// --relay link silently go relay-only).
if (options.relay) {
logStatus('warn', '[LAN_UNREACHABLE]', 'OpenChamber only listens on this machine, so devices will always connect through the relay. Restart with --lan to allow direct home-network connections.');
} else {
logStatus('warn', '[LAN_UNREACHABLE]', 'OpenChamber only listens on this machine, so other devices cannot use this link. Restart with --lan, or use --server to provide a reachable URL.');
}
}
clackLog.info('Scan or paste this link into another OpenChamber client. It is single-use and expires.');
if (options.qr === true) {