mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Protect relay sessions against replayed commands with authenticated Noise NK negotiation, ordered records, and identity-bound security pins. Preserve legacy admission during migration and add daemon enforcement policy. All 21 CI checks passed. General engineering review is complete; specialist cryptographic review of the handwritten Noise implementation and static-key reuse remains required before release or deployment. Closes #359
224 lines
7.0 KiB
TypeScript
224 lines
7.0 KiB
TypeScript
import { confirm, isCancel, log } from "@clack/prompts";
|
|
import { Command } from "commander";
|
|
import chalk from "chalk";
|
|
import { generateLocalPairingOffer } from "@getpaseo/server/pairing";
|
|
import { readDaemonInstance } from "@getpaseo/server/daemon-control";
|
|
import {
|
|
readPersistedConfig,
|
|
editPersistedConfig,
|
|
resolveConfigFromPersisted,
|
|
} from "@getpaseo/server/configuration";
|
|
import { connectToDaemon } from "../../utils/client.js";
|
|
import type { DaemonTarget } from "../../utils/daemon-target.js";
|
|
import { addJsonAndDaemonHostOptions, withGlobalOptions } from "../../utils/command-options.js";
|
|
import { formatPairingInstructions } from "../../output/pairing.js";
|
|
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
|
import { serializeRelayConnectionUri } from "@getpaseo/protocol/daemon-endpoints";
|
|
|
|
interface PairOptions {
|
|
daemonTarget: DaemonTarget;
|
|
home?: string;
|
|
json?: boolean;
|
|
relay?: boolean;
|
|
}
|
|
|
|
export interface PairCommandOutput {
|
|
columns: number | undefined;
|
|
writeStdout(message: string): void;
|
|
writeStderr(message: string): void;
|
|
setExitCode(code: number): void;
|
|
success(message: string): void;
|
|
}
|
|
|
|
export interface PairingOffer {
|
|
relayEnabled: boolean;
|
|
url: string | null;
|
|
qr: string | null;
|
|
}
|
|
|
|
const PAIRING_DAEMON_RPC_TIMEOUT_MS = 1500;
|
|
const RELAY_DOCS_URL = "https://paseo.sh/docs/security";
|
|
|
|
function createProcessOutput(): PairCommandOutput {
|
|
return {
|
|
columns: process.stdout.columns,
|
|
writeStdout(message) {
|
|
process.stdout.write(message);
|
|
},
|
|
writeStderr(message) {
|
|
process.stderr.write(message);
|
|
},
|
|
setExitCode(code) {
|
|
process.exitCode = code;
|
|
},
|
|
success(message) {
|
|
log.success(message);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function pairCommand(): Command {
|
|
return addJsonAndDaemonHostOptions(
|
|
new Command("pair").description("Print the daemon pairing QR code and link"),
|
|
)
|
|
.option("--relay", "Enable relay without prompting")
|
|
.action(
|
|
withGlobalOptions((options: PairOptions, _command: Command) => runPairCommand(options)),
|
|
);
|
|
}
|
|
|
|
export async function resolveLocalPairingOffer(options: {
|
|
paseoHome: string;
|
|
enableRelay?: boolean;
|
|
}): Promise<PairingOffer> {
|
|
const instance = await readDaemonInstance(options.paseoHome);
|
|
if (instance)
|
|
return resolveDaemonPairingOffer(
|
|
{ kind: "instance", home: options.paseoHome },
|
|
options.enableRelay,
|
|
);
|
|
if (options.enableRelay)
|
|
editPersistedConfig(options.paseoHome, "daemon.relay.enabled", { value: true });
|
|
const config = resolveConfigFromPersisted(
|
|
options.paseoHome,
|
|
readPersistedConfig(options.paseoHome, { defaultsIfMissing: true }),
|
|
{ env: {} },
|
|
);
|
|
|
|
return generateLocalPairingOffer({
|
|
paseoHome: options.paseoHome,
|
|
relayEnabled: config.relayEnabled,
|
|
relayEndpoint: config.relayEndpoint,
|
|
relayPublicEndpoint: config.relayPublicEndpoint,
|
|
relayUseTls: config.relayUseTls,
|
|
relayPublicUseTls: config.relayPublicUseTls,
|
|
relayRequireProtectedSecurity: config.relayRequireProtectedSecurity,
|
|
appBaseUrl: config.appBaseUrl,
|
|
includeQr: true,
|
|
});
|
|
}
|
|
|
|
async function resolveDaemonPairingOffer(
|
|
target: DaemonTarget,
|
|
enableRelay: boolean | undefined,
|
|
): Promise<PairingOffer> {
|
|
const client = await connectToDaemon({
|
|
target,
|
|
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
|
|
});
|
|
|
|
try {
|
|
const serverInfo = client.getLastServerInfoMessage();
|
|
if (serverInfo?.features?.daemonStatusRpc !== true) {
|
|
throw new Error("Update the Paseo daemon before pairing from this command.");
|
|
}
|
|
|
|
let offer = await client.getDaemonPairingOffer({
|
|
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
|
|
});
|
|
if (!offer.relayEnabled && enableRelay) {
|
|
if (serverInfo.features.relayConfig !== true) {
|
|
throw new Error("Update the Paseo daemon before enabling relay from this command.");
|
|
}
|
|
await client.patchDaemonConfig({ relay: { enabled: true } });
|
|
try {
|
|
offer = await client.getDaemonPairingOffer({ timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS });
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Relay configuration was saved, but fetching the pairing offer failed: ${String(error)}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
}
|
|
return {
|
|
relayEnabled: offer.relayEnabled,
|
|
url: offer.url || null,
|
|
qr: offer.qr ?? null,
|
|
};
|
|
} finally {
|
|
await client.close().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
export async function confirmRelayPairing(): Promise<boolean> {
|
|
log.message("Your connection is end-to-end encrypted. Paseo cannot read your code or messages.");
|
|
log.message(`Learn how it works: ${RELAY_DOCS_URL}`);
|
|
const answer = await confirm({
|
|
message: "Enable relay to pair a device?",
|
|
initialValue: false,
|
|
});
|
|
return !isCancel(answer) && answer;
|
|
}
|
|
|
|
export function printDirectConnectionGuidance(): void {
|
|
console.log("Daemon is running with relay off.");
|
|
console.log(
|
|
"To connect another device directly, use the daemon's TCP address over your LAN, Tailscale, or another VPN.",
|
|
);
|
|
console.log(`Learn more: ${RELAY_DOCS_URL}#direct-connections`);
|
|
}
|
|
|
|
export async function runPairCommand(options: PairOptions): Promise<void> {
|
|
const output = createProcessOutput();
|
|
const target = options.daemonTarget;
|
|
const resolveOffer = (enableRelay: boolean) =>
|
|
target.kind === "instance"
|
|
? resolveLocalPairingOffer({ paseoHome: target.home, enableRelay })
|
|
: resolveDaemonPairingOffer(target, enableRelay);
|
|
const offline = target.kind === "instance" && !(await readDaemonInstance(target.home));
|
|
const pairing = await resolveOffer(options.relay === true);
|
|
|
|
if (offline)
|
|
output.writeStderr(
|
|
`Offline pairing offer. Start with: paseo daemon start --home ${JSON.stringify(target.kind === "instance" ? target.home : "")}\n`,
|
|
);
|
|
|
|
outputPairingResult(pairing, options, output);
|
|
}
|
|
|
|
function outputPairingResult(
|
|
pairing: PairingOffer,
|
|
options: PairOptions,
|
|
output: PairCommandOutput,
|
|
): void {
|
|
if (!pairing.relayEnabled || !pairing.url) {
|
|
if (options.json) {
|
|
output.writeStderr(
|
|
`${JSON.stringify({
|
|
code: "RELAY_DISABLED",
|
|
message: "Relay pairing is disabled for this daemon.",
|
|
action: "Run paseo daemon pair --relay --json to enable it explicitly.",
|
|
})}\n`,
|
|
);
|
|
} else {
|
|
output.writeStderr(`${chalk.red("Relay pairing is disabled for this daemon.")}\n`);
|
|
output.writeStderr(`${chalk.yellow("Run paseo daemon pair --relay to enable it.")}\n`);
|
|
}
|
|
output.setExitCode(1);
|
|
return;
|
|
}
|
|
|
|
const offer = parseConnectionOfferFromUrl(pairing.url);
|
|
const connectionUri = offer ? serializeRelayConnectionUri({ offer }) : null;
|
|
|
|
if (options.json) {
|
|
output.writeStdout(
|
|
`${JSON.stringify(
|
|
{ relayEnabled: pairing.relayEnabled, url: pairing.url, qr: pairing.qr, connectionUri },
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
output.writeStdout(
|
|
formatPairingInstructions({
|
|
url: pairing.url,
|
|
qr: pairing.qr,
|
|
connectionUri,
|
|
columns: output.columns,
|
|
}),
|
|
);
|
|
}
|