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 @@
11 import * as RelayClient from "@t3tools/shared/relayClient";
22 import { assert, it } from "@effect/vitest";
33 import * as Cause from "effect/Cause";
4+import * as ConfigProvider from "effect/ConfigProvider";
45 import * as Console from "effect/Console";
56 import * as Effect from "effect/Effect";
67 import * as Exit from "effect/Exit";
78 import * as Logger from "effect/Logger";
89 import * as Option from "effect/Option";
910 import * as References from "effect/References";
11+import * as Terminal from "effect/Terminal";
1012
1113 import {
1214 acquireRelayClientForLink,
15+ formatHeadlessAuthorizationPrompt,
16+ formatRelayClientReady,
17+ headlessSessionConfig,
1318 isPublishAgentActivityEnabledValue,
19+ recoverBootServiceOffer,
1420 reportCloudDisconnectResults,
1521 } from "./connect.ts";
1622
23+it("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+
36+it("formats relay readiness without printing its installation path", () => {
37+ assert.equal(formatRelayClientReady("2026.5.2"), "✓ Relay client ready · cloudflared 2026.5.2");
38+});
39+
40+const readHeadlessSessionConfig = (env: Record<string, string>) =>
41+ headlessSessionConfig.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))));
42+
1743 const managedExecutable = {
1844 status: "available",
1945 executablePath: "/tmp/cloudflared",
@@ -21,6 +47,22 @@ const managedExecutable = { · 1 unmodified line
2147 version: RelayClient.CLOUDFLARED_VERSION,
2248 } as const;
2349
50+it.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+
59+it.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+
2466 it.effect("does not install the relay client when the user declines the managed download", () =>
2567 Effect.gen(function* () {
2668 let installCalls = 0;
apps/server/src/cli/connect.ts+245−56
@@ -6,9 +6,12 @@ import { · 5 unmodified lines
66 } from "@t3tools/contracts";
77 import { RelayOkResponse } from "@t3tools/contracts/relay";
88 import * as RelayClient from "@t3tools/shared/relayClient";
9+import * as Terminal from "effect/Terminal";
910 import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
1011 import * as Cause from "effect/Cause";
12+import * as Config from "effect/Config";
1113 import * as Console from "effect/Console";
14+import * as Crypto from "effect/Crypto";
1215 import * as Duration from "effect/Duration";
1316 import * as Effect from "effect/Effect";
1417 import * as Exit from "effect/Exit";
@@ -16,6 +19,7 @@ import * as FileSystem from "effect/FileSystem"; · 1 unmodified line
1619 import * as Layer from "effect/Layer";
1720 import * as Option from "effect/Option";
1821 import * as References from "effect/References";
22+import * as Schema from "effect/Schema";
1923 import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
2024 import {
2125 FetchHttpClient,
@@ -25,8 +29,10 @@ import { · 3 unmodified lines
2529 } from "effect/unstable/http";
2630 import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
2731
32+import packageJson from "../../package.json" with { type: "json" };
2833 import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
2934 import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
35+import * as BootService from "../cloud/bootService.ts";
3036 import * as CliState from "../cloud/CliState.ts";
3137 import * as CliTokenManager from "../cloud/CliTokenManager.ts";
3238 import {
@@ -38,6 +44,8 @@ import { relayUrlConfig } from "../cloud/publicConfig.ts"; · 5 unmodified lines
3844 import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";
3945 import * as ServerConfig from "../config.ts";
4046 import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
47+import * as ExternalLauncher from "../process/externalLauncher.ts";
48+import * as ProcessRunner from "../processRunner.ts";
4149 import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
4250 import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
4351
@@ -46,6 +54,81 @@ const jsonFlag = Flag.boolean("json").pipe( · 2 unmodified lines
4654 Flag.withDefault(false),
4755 );
4856
57+const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError);
58+
59+const 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+ */
68+export 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+
75+const 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+
82+export 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. */
93+const 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+
49132 function bytesToString(value: Uint8Array): string {
50133 return new TextDecoder().decode(value);
51134 }
@@ -313,6 +396,21 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { · 261 unmodified lines
313396 if (options.clearAuthorization) {
314397 const tokens = yield* CliTokenManager.CloudCliTokenManager;
315398 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+ );
316414 }
317415
318416 yield* reportCloudDisconnectResults({
@@ -326,7 +424,7 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { · 7 unmodified lines
326424 }
327425 });
328426
329−const runCloudCommand = <A, E>(
427+const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E>(
330428 flags: { readonly baseDir: Option.Option<string> },
331429 run: Effect.Effect<
332430 A,
@@ -335,6 +433,8 @@ const runCloudCommand = <A, E>( · 2 unmodified lines
335433 | CliTokenManager.CloudCliTokenManager
336434 | RelayClient.RelayClient
337435 | EnvironmentAuth.EnvironmentAuth
436+ | BootService.BootService
437+ | Crypto.Crypto
338438 | FileSystem.FileSystem
339439 | HttpClient.HttpClient
340440 | Prompt.Environment
@@ -344,37 +444,79 @@ const runCloudCommand = <A, E>( · 3 unmodified lines
344444 options?: {
345445 readonly quietLogs?: boolean;
346446 },
347−) =>
348− Effect.gen(function* () {
349− const logLevel = yield* GlobalFlag.LogLevel;
350− const config = yield* resolveCliAuthConfig(flags, logLevel);
351− const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
352− const runtimeLayer = Layer.mergeAll(
353− ServerSecretStore.layer,
354− CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)),
355− RelayClient.layerCloudflared({ baseDir: config.baseDir }),
356− EnvironmentAuth.runtimeLayer,
357− ServerEnvironment.layer,
358− headlessRelayClientTracingLayer,
359− ).pipe(
360− Layer.provideMerge(FetchHttpClient.layer),
361− Layer.provideMerge(ServerConfig.layer(config)),
362− Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
447+) {
448+ const logLevel = yield* GlobalFlag.LogLevel;
449+ const config = yield* resolveCliAuthConfig(flags, logLevel);
450+ const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
451+ const runtimeLayer = Layer.mergeAll(
452+ ServerSecretStore.layer,
453+ CliTokenManager.layer.pipe(
454+ Layer.provide(ServerSecretStore.layer),
455+ Layer.provide(ExternalLauncher.layer),
456+ ),
457+ RelayClient.layerCloudflared({ baseDir: config.baseDir }),
458+ EnvironmentAuth.runtimeLayer,
459+ ServerEnvironment.layer,
460+ BootService.layer({
461+ baseDir: config.baseDir,
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+
474+const connectedAs = (identity: string | null): string => (identity ? ` as ${identity}` : "");
475+
476+export function formatRelayClientReady(version: string): string {
477+ return `✓ Relay client ready · cloudflared ${version}`;
478+}
479+
480+const 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,
363491 );
364− return yield* run.pipe(Effect.provide(runtimeLayer));
365− });
492+ if (Option.isNone(installed)) {
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+});
366507
367508 const connectLoginCommand = Command.make("login", {
368509 ...projectLocationFlags,
510+ headless: headlessFlag,
369511 }).pipe(
370512 Command.withDescription("Authorize the T3 Connect CLI without enabling remote access."),
371513 Command.withHandler((flags) =>
372514 runCloudCommand(
373515 flags,
374516 Effect.gen(function* () {
375− const tokens = yield* CliTokenManager.CloudCliTokenManager;
376− yield* tokens.get;
377− yield* Console.log("Signed in to T3 Connect.");
517+ yield* Console.log("T3 Connect\n");
518+ const identity = yield* authorizeCli(flags);
519+ yield* Console.log(`✓ Signed in${connectedAs(identity)}`);
378520 }),
379521 ),
380522 ),
@@ -382,6 +524,7 @@ const connectLoginCommand = Command.make("login", { · 1 unmodified line
382524
383525 const connectLinkCommand = Command.make("link", {
384526 ...projectLocationFlags,
527+ headless: headlessFlag,
385528 publishOnly: Flag.boolean("publish-only").pipe(
386529 Flag.withDescription(
387530 "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
394537 runCloudCommand(
395538 flags,
396539 Effect.gen(function* () {
397− // A publish-only link needs no Cloudflare tunnel, so skip installing the
398− // relay client entirely.
399− if (!flags.publishOnly) {
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− }
540+ yield* Console.log("T3 Connect\n");
541+ const linked = yield* linkEnvironmentForConnect(flags);
542+ if (linked) {
410543 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.`,
412547 );
413548 }
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− );
432549 }),
433550 ),
434551 ),
@@ -566,8 +683,80 @@ const connectLogoutCommand = Command.make("logout", { · 131 unmodified lines
566683 ),
567684 );
568685
569−export const connectCommand = Command.make("connect").pipe(
570− Command.withDescription("Manage headless T3 Connect access."),
686+const offerBootService = Effect.gen(function* () {
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+
715+export 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+
730+export 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+ ),
571760 Command.withSubcommands([
572761 connectLoginCommand,
573762 connectLinkCommand,