History

feat: add headless `t3 connect` setup for SSH hosts (#3749)

Theo Browne committed 3 months ago

Commit 7a820ab1 parent 53e3c9835 files · +3017 −144Browse files

Showing the changes to apps/server/src/cli (2 of 35 files). Show all

Unified · Split

2 files
  1. apps/server/src/cli/connect.test.ts +42 −0
  2. apps/server/src/cli/connect.ts +245 −56
apps/server/src/cli/connect.test.ts+42−0
@@ -1,19 +1,45 @@
1import * as RelayClient from "@t3tools/shared/relayClient";1import * as RelayClient from "@t3tools/shared/relayClient";
2import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
3import * as Cause from "effect/Cause";3import * as Cause from "effect/Cause";
4import * as ConfigProvider from "effect/ConfigProvider";
4import * as Console from "effect/Console";5import * as Console from "effect/Console";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";7import * as Exit from "effect/Exit";
7import * as Logger from "effect/Logger";8import * as Logger from "effect/Logger";
8import * as Option from "effect/Option";9import * as Option from "effect/Option";
9import * as References from "effect/References";10import * as References from "effect/References";
11import * as Terminal from "effect/Terminal";
10 12
11import {13import {
12 acquireRelayClientForLink,14 acquireRelayClientForLink,
15 formatHeadlessAuthorizationPrompt,
16 formatRelayClientReady,
17 headlessSessionConfig,
13 isPublishAgentActivityEnabledValue,18 isPublishAgentActivityEnabledValue,
19 recoverBootServiceOffer,
14 reportCloudDisconnectResults,20 reportCloudDisconnectResults,
15} from "./connect.ts";21} from "./connect.ts";
16 22
23it("explains how to complete headless authorization", () => {
24 assert.equal(
25 formatHeadlessAuthorizationPrompt("https://example.test/connect"),
26 [
27 "Headless authorization",
28 "Open this URL on a device with a browser:",
29 " https://example.test/connect",
30 "",
31 "After signing in, return here and enter the code shown in your browser.",
32 ].join("\n"),
33 );
34});
35
36it("formats relay readiness without printing its installation path", () => {
37 assert.equal(formatRelayClientReady("2026.5.2"), "✓ Relay client ready · cloudflared 2026.5.2");
38});
39
40const readHeadlessSessionConfig = (env: Record<string, string>) =>
41 headlessSessionConfig.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))));
42
17const managedExecutable = {43const managedExecutable = {
18 status: "available",44 status: "available",
19 executablePath: "/tmp/cloudflared",45 executablePath: "/tmp/cloudflared",
@@ -21,6 +47,22 @@ const managedExecutable = { · 1 unmodified line
21 version: RelayClient.CLOUDFLARED_VERSION,47 version: RelayClient.CLOUDFLARED_VERSION,
22} as const;48} as const;
23 49
50it.effect("detects headless operation from individual SSH config values", () =>
51 Effect.gen(function* () {
52 assert.isFalse(yield* readHeadlessSessionConfig({}));
53 assert.isFalse(yield* readHeadlessSessionConfig({ CI: "true" }));
54 assert.isTrue(yield* readHeadlessSessionConfig({ SSH_CONNECTION: "client server" }));
55 assert.isTrue(yield* readHeadlessSessionConfig({ SSH_TTY: "/dev/pts/1" }));
56 }),
57);
58
59it.effect("treats cancelling optional background setup as a successful skip", () =>
60 Effect.gen(function* () {
61 const result = yield* recoverBootServiceOffer(Effect.fail(new Terminal.QuitError({})));
62 assert.isFalse(result);
63 }),
64);
65
24it.effect("does not install the relay client when the user declines the managed download", () =>66it.effect("does not install the relay client when the user declines the managed download", () =>
25 Effect.gen(function* () {67 Effect.gen(function* () {
26 let installCalls = 0;68 let installCalls = 0;
apps/server/src/cli/connect.ts+245−56
@@ -6,9 +6,12 @@ import { · 5 unmodified lines
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { RelayOkResponse } from "@t3tools/contracts/relay";7import { RelayOkResponse } from "@t3tools/contracts/relay";
8import * as RelayClient from "@t3tools/shared/relayClient";8import * as RelayClient from "@t3tools/shared/relayClient";
9import * as Terminal from "effect/Terminal";
9import { withRelayClientTracing } from "@t3tools/shared/relayTracing";10import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
10import * as Cause from "effect/Cause";11import * as Cause from "effect/Cause";
12import * as Config from "effect/Config";
11import * as Console from "effect/Console";13import * as Console from "effect/Console";
14import * as Crypto from "effect/Crypto";
12import * as Duration from "effect/Duration";15import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";16import * as Effect from "effect/Effect";
14import * as Exit from "effect/Exit";17import * as Exit from "effect/Exit";
@@ -16,6 +19,7 @@ import * as FileSystem from "effect/FileSystem"; · 1 unmodified line
16import * as Layer from "effect/Layer";19import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";20import * as Option from "effect/Option";
18import * as References from "effect/References";21import * as References from "effect/References";
22import * as Schema from "effect/Schema";
19import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";23import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
20import {24import {
21 FetchHttpClient,25 FetchHttpClient,
@@ -25,8 +29,10 @@ import { · 3 unmodified lines
25} from "effect/unstable/http";29} from "effect/unstable/http";
26import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";30import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
27 31
32import packageJson from "../../package.json" with { type: "json" };
28import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";33import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
29import * as ServerSecretStore from "../auth/ServerSecretStore.ts";34import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
35import * as BootService from "../cloud/bootService.ts";
30import * as CliState from "../cloud/CliState.ts";36import * as CliState from "../cloud/CliState.ts";
31import * as CliTokenManager from "../cloud/CliTokenManager.ts";37import * as CliTokenManager from "../cloud/CliTokenManager.ts";
32import {38import {
@@ -38,6 +44,8 @@ import { relayUrlConfig } from "../cloud/publicConfig.ts"; · 5 unmodified lines
38import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";44import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";
39import * as ServerConfig from "../config.ts";45import * as ServerConfig from "../config.ts";
40import * as ServerEnvironment from "../environment/ServerEnvironment.ts";46import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
47import * as ExternalLauncher from "../process/externalLauncher.ts";
48import * as ProcessRunner from "../processRunner.ts";
41import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";49import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
42import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";50import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
43 51
@@ -46,6 +54,81 @@ const jsonFlag = Flag.boolean("json").pipe( · 2 unmodified lines
46 Flag.withDefault(false),54 Flag.withDefault(false),
47);55);
48 56
57const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError);
58
59const headlessFlag = Flag.boolean("headless").pipe(
60 Flag.withDescription("Authorize without a local browser using out-of-band OAuth."),
61 Flag.withDefault(false),
62);
63
64/**
65 * Inside an SSH session there is no local browser to complete the loopback
66 * OAuth callback, so out-of-band OAuth is the only flow that can work.
67 */
68export const headlessSessionConfig = Config.all({
69 sshConnection: Config.string("SSH_CONNECTION").pipe(Config.option),
70 sshTty: Config.string("SSH_TTY").pipe(Config.option),
71}).pipe(
72 Config.map(({ sshConnection, sshTty }) => Option.isSome(sshConnection) || Option.isSome(sshTty)),
73);
74
75const promptForOutOfBandOAuthCode = Effect.fn("cloud.cli.prompt_for_out_of_band_oauth_code")(
76 function* ({ authorizeUrl, validate }: CliTokenManager.OutOfBandOAuthPromptInput) {
77 yield* Console.log(formatHeadlessAuthorizationPrompt(authorizeUrl));
78 return yield* Prompt.run(Prompt.text({ message: "Authorization code", validate }));
79 },
80);
81
82export function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string {
83 return [
84 "Headless authorization",
85 "Open this URL on a device with a browser:",
86 ` ${authorizeUrl}`,
87 "",
88 "After signing in, return here and enter the code shown in your browser.",
89 ].join("\n");
90}
91
92/** Returns the connected account identity, if the flow could determine one. */
93const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: {
94 readonly headless: boolean;
95}) {
96 const tokens = yield* CliTokenManager.CloudCliTokenManager;
97 const useOutOfBandOAuth = options.headless || (yield* headlessSessionConfig);
98 if (!useOutOfBandOAuth) {
99 const authorization = yield* tokens.get;
100 if (authorization._tag === "Authorized") {
101 return authorization.token.identity ?? null;
102 }
103 yield* Console.log("\nHeadless mode enabled. A new authorization link is ready below.");
104 }
105 // A stored credential whose refresh fails (revoked, expired grant) must
106 // fall through to a fresh out-of-band authorization, not dead-end the command.
107 const existing = yield* tokens.getExisting.pipe(
108 Effect.catchTag("CloudCliCredentialRefreshError", () =>
109 Console.log(
110 "The stored T3 Connect credential could not be refreshed; signing in again.",
111 ).pipe(Effect.as(Option.none())),
112 ),
113 );
114 if (Option.isSome(existing)) {
115 return existing.value.identity ?? null;
116 }
117 const { token, identity } = yield* CliTokenManager.outOfBandOAuthLogin(
118 promptForOutOfBandOAuthCode,
119 ).pipe(
120 Effect.mapError((cause) =>
121 // Ctrl-C / EOF at the prompt is a QuitError; let it propagate so the CLI
122 // cancels quietly instead of dumping an authorization error.
123 Terminal.isQuitError(cause) || isCloudCliTokenManagerError(cause)
124 ? cause
125 : new CliTokenManager.CloudCliAuthorizationError({ cause }),
126 ),
127 );
128 yield* tokens.store(token);
129 return identity;
130});
131
49function bytesToString(value: Uint8Array): string {132function bytesToString(value: Uint8Array): string {
50 return new TextDecoder().decode(value);133 return new TextDecoder().decode(value);
51}134}
@@ -313,6 +396,21 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { · 261 unmodified lines
313 if (options.clearAuthorization) {396 if (options.clearAuthorization) {
314 const tokens = yield* CliTokenManager.CloudCliTokenManager;397 const tokens = yield* CliTokenManager.CloudCliTokenManager;
315 yield* tokens.clear;398 yield* tokens.clear;
399
400 // uninstall itself no-ops when nothing is installed (and on non-Linux),
401 // so no status pre-check that could mask a real removal failure.
402 const bootService = yield* BootService.BootService;
403 yield* bootService.uninstall.pipe(
404 Effect.tap((removed) =>
405 removed ? Console.log("Removed the T3 Code background service.") : Effect.void,
406 ),
407 Effect.catchTag("BootServiceUnsupportedError", () => Effect.succeed(false)),
408 Effect.catch((error) =>
409 Console.warn(`Could not remove the background service: ${error.message}`).pipe(
410 Effect.as(false),
411 ),
412 ),
413 );
316 }414 }
317 415
318 yield* reportCloudDisconnectResults({416 yield* reportCloudDisconnectResults({
@@ -326,7 +424,7 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { · 7 unmodified lines
326 }424 }
327});425});
328 426
329const runCloudCommand = <A, E>(427const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E>(
330 flags: { readonly baseDir: Option.Option<string> },428 flags: { readonly baseDir: Option.Option<string> },
331 run: Effect.Effect<429 run: Effect.Effect<
332 A,430 A,
@@ -335,6 +433,8 @@ const runCloudCommand = <A, E>( · 2 unmodified lines
335 | CliTokenManager.CloudCliTokenManager433 | CliTokenManager.CloudCliTokenManager
336 | RelayClient.RelayClient434 | RelayClient.RelayClient
337 | EnvironmentAuth.EnvironmentAuth435 | EnvironmentAuth.EnvironmentAuth
436 | BootService.BootService
437 | Crypto.Crypto
338 | FileSystem.FileSystem438 | FileSystem.FileSystem
339 | HttpClient.HttpClient439 | HttpClient.HttpClient
340 | Prompt.Environment440 | Prompt.Environment
@@ -344,37 +444,79 @@ const runCloudCommand = <A, E>( · 3 unmodified lines
344 options?: {444 options?: {
345 readonly quietLogs?: boolean;445 readonly quietLogs?: boolean;
346 },446 },
347) =>447) {
348 Effect.gen(function* () {448 const logLevel = yield* GlobalFlag.LogLevel;
349 const logLevel = yield* GlobalFlag.LogLevel;449 const config = yield* resolveCliAuthConfig(flags, logLevel);
350 const config = yield* resolveCliAuthConfig(flags, logLevel);450 const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
351 const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;451 const runtimeLayer = Layer.mergeAll(
352 const runtimeLayer = Layer.mergeAll(452 ServerSecretStore.layer,
353 ServerSecretStore.layer,453 CliTokenManager.layer.pipe(
354 CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)),454 Layer.provide(ServerSecretStore.layer),
355 RelayClient.layerCloudflared({ baseDir: config.baseDir }),455 Layer.provide(ExternalLauncher.layer),
356 EnvironmentAuth.runtimeLayer,456 ),
357 ServerEnvironment.layer,457 RelayClient.layerCloudflared({ baseDir: config.baseDir }),
358 headlessRelayClientTracingLayer,458 EnvironmentAuth.runtimeLayer,
359 ).pipe(459 ServerEnvironment.layer,
360 Layer.provideMerge(FetchHttpClient.layer),460 BootService.layer({
361 Layer.provideMerge(ServerConfig.layer(config)),461 baseDir: config.baseDir,
362 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),462 logsDir: config.logsDir,
463 cliVersion: packageJson.version,
464 }).pipe(Layer.provide(ProcessRunner.layer)),
465 headlessRelayClientTracingLayer,
466 ).pipe(
467 Layer.provideMerge(FetchHttpClient.layer),
468 Layer.provideMerge(ServerConfig.layer(config)),
469 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
470 );
471 return yield* run.pipe(Effect.provide(runtimeLayer));
472});
473
474const connectedAs = (identity: string | null): string => (identity ? ` as ${identity}` : "");
475
476export function formatRelayClientReady(version: string): string {
477 return `✓ Relay client ready · cloudflared ${version}`;
478}
479
480const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(function* (options: {
481 readonly headless: boolean;
482 readonly publishOnly?: boolean;
483}) {
484 const publishOnly = options.publishOnly ?? false;
485 if (!publishOnly) {
486 const relayClient = yield* RelayClient.RelayClient;
487 const installed = yield* acquireRelayClientForLink(
488 relayClient,
489 confirmRelayClientInstall,
490 reportRelayClientInstallProgress,
363 );491 );
364 return yield* run.pipe(Effect.provide(runtimeLayer));492 if (Option.isNone(installed)) {
365 });493 yield* Console.log("T3 Connect setup cancelled. The relay client was not installed.");
494 return null;
495 }
496 yield* Console.log(formatRelayClientReady(installed.value.version));
497 }
498
499 const identity = yield* authorizeCli(options);
500 yield* CliState.setCliDesiredCloudLink(true, publishOnly ? "publish_only" : "managed");
501 if (publishOnly) {
502 const secrets = yield* ServerSecretStore.ServerSecretStore;
503 yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true"));
504 }
505 return { identity } as const;
506});
366 507
367const connectLoginCommand = Command.make("login", {508const connectLoginCommand = Command.make("login", {
368 ...projectLocationFlags,509 ...projectLocationFlags,
510 headless: headlessFlag,
369}).pipe(511}).pipe(
370 Command.withDescription("Authorize the T3 Connect CLI without enabling remote access."),512 Command.withDescription("Authorize the T3 Connect CLI without enabling remote access."),
371 Command.withHandler((flags) =>513 Command.withHandler((flags) =>
372 runCloudCommand(514 runCloudCommand(
373 flags,515 flags,
374 Effect.gen(function* () {516 Effect.gen(function* () {
375 const tokens = yield* CliTokenManager.CloudCliTokenManager;517 yield* Console.log("T3 Connect\n");
376 yield* tokens.get;518 const identity = yield* authorizeCli(flags);
377 yield* Console.log("Signed in to T3 Connect.");519 yield* Console.log(`✓ Signed in${connectedAs(identity)}`);
378 }),520 }),
379 ),521 ),
380 ),522 ),
@@ -382,6 +524,7 @@ const connectLoginCommand = Command.make("login", { · 1 unmodified line
382 524
383const connectLinkCommand = Command.make("link", {525const connectLinkCommand = Command.make("link", {
384 ...projectLocationFlags,526 ...projectLocationFlags,
527 headless: headlessFlag,
385 publishOnly: Flag.boolean("publish-only").pipe(528 publishOnly: Flag.boolean("publish-only").pipe(
386 Flag.withDescription(529 Flag.withDescription(
387 "Link to publish agent activity only — no managed tunnel. Reach this environment out of band (e.g. Tailscale).",530 "Link to publish agent activity only — no managed tunnel. Reach this environment out of band (e.g. Tailscale).",
@@ -394,41 +537,15 @@ const connectLinkCommand = Command.make("link", { · 6 unmodified lines
394 runCloudCommand(537 runCloudCommand(
395 flags,538 flags,
396 Effect.gen(function* () {539 Effect.gen(function* () {
397 // A publish-only link needs no Cloudflare tunnel, so skip installing the540 yield* Console.log("T3 Connect\n");
398 // relay client entirely.541 const linked = yield* linkEnvironmentForConnect(flags);
399 if (!flags.publishOnly) {542 if (linked) {
400 const relayClient = yield* RelayClient.RelayClient;
401 const installed = yield* acquireRelayClientForLink(
402 relayClient,
403 confirmRelayClientInstall,
404 reportRelayClientInstallProgress,
405 );
406 if (Option.isNone(installed)) {
407 yield* Console.log("T3 Connect setup cancelled. The relay client was not installed.");
408 return;
409 }
410 yield* Console.log(543 yield* Console.log(
411 `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`,544 flags.publishOnly
545 ? `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start T3 to publish agent activity (no managed tunnel).`
546 : `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start the server with \`t3 serve\` to make this machine reachable.`,
412 );547 );
413 }548 }
414
415 const tokens = yield* CliTokenManager.CloudCliTokenManager;
416 yield* tokens.get;
417 yield* CliState.setCliDesiredCloudLink(
418 true,
419 flags.publishOnly ? "publish_only" : "managed",
420 );
421 if (flags.publishOnly) {
422 // A publish-only link exists solely to publish; without the publish
423 // flag the link would be inert and the success message a lie.
424 const secrets = yield* ServerSecretStore.ServerSecretStore;
425 yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true"));
426 }
427 yield* Console.log(
428 flags.publishOnly
429 ? "This environment will publish agent activity to your mobile clients the next time T3 starts (no managed tunnel)."
430 : "This T3 environment will be available through T3 Connect the next time T3 starts.",
431 );
432 }),549 }),
433 ),550 ),
434 ),551 ),
@@ -566,8 +683,80 @@ const connectLogoutCommand = Command.make("logout", { · 131 unmodified lines
566 ),683 ),
567);684);
568 685
569export const connectCommand = Command.make("connect").pipe(686const offerBootService = Effect.gen(function* () {
570 Command.withDescription("Manage headless T3 Connect access."),687 const bootService = yield* BootService.BootService;
688 const { supported, installed, current } = yield* bootService.status;
689 if (!supported) {
690 // Don't prompt for something that can only fail; background setup is
691 // Linux/systemd-only for now.
692 return false;
693 }
694 if (installed && current) {
695 yield* Console.log("T3 Code is already set up to run in the background on this machine.");
696 return true;
697 }
698 const wanted = yield* Prompt.run(
699 Prompt.confirm({
700 message: installed
701 ? "The installed T3 Code background service is from an older setup. Update it now?"
702 : "Run T3 Code in the background whenever this machine boots? " +
703 "It stays reachable through T3 Connect even after you log out.",
704 initial: true,
705 }),
706 );
707 if (!wanted) {
708 return false;
709 }
710 const plan = yield* bootService.install;
711 yield* Console.log(`Background service installed. Logs: ${plan.logPath}`);
712 return true;
713});
714
715export const recoverBootServiceOffer = <R>(
716 offer: Effect.Effect<boolean, BootService.BootServiceError | Terminal.QuitError, R>,
717) =>
718 offer.pipe(
719 Effect.catchTags({
720 QuitError: () => Effect.succeed(false),
721 BootServiceUnsupportedError: (error) =>
722 Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)),
723 BootServiceCommandError: (error) =>
724 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
725 BootServiceInstallError: (error) =>
726 Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
727 }),
728 );
729
730export const connectCommand = Command.make("connect", {
731 ...projectLocationFlags,
732 headless: headlessFlag,
733}).pipe(
734 Command.withDescription("Set up T3 Connect for this machine."),
735 Command.withHandler((flags) =>
736 runCloudCommand(
737 flags,
738 Effect.gen(function* () {
739 yield* Console.log("T3 Connect\n");
740 const linked = yield* linkEnvironmentForConnect(flags);
741 if (!linked) {
742 return;
743 }
744 // Show which account was linked so an unexpected identity (an
745 // authorization code for a different account) is visible before the
746 // machine is brought online.
747 yield* Console.log(`✓ Connected${connectedAs(linked.identity)}`);
748
749 // Connect itself already succeeded; a boot-service failure must not
750 // fail the command, just tell the user what happened and move on.
751 const background = yield* recoverBootServiceOffer(offerBootService);
752 yield* Console.log(
753 background
754 ? "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out."
755 : "\nNext\n Start the server with `t3 serve` to make this machine reachable.",
756 );
757 }),
758 ),
759 ),
571 Command.withSubcommands([760 Command.withSubcommands([
572 connectLoginCommand,761 connectLoginCommand,
573 connectLinkCommand,762 connectLinkCommand,