Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { EnvironmentId } from "@t3tools/contracts";
import Constants from "expo-constants";
import * as Option from "effect/Option";
import { AsyncResult } from "effect/reactivity";
import { View } from "react-native";
import { Linking, View } from "react-native";

import { AppText as Text } from "../../components/ui/AppText";
import { Button } from "../../components/ui/Button";
Expand Down Expand Up @@ -52,6 +52,7 @@ export function ConnectionDiagnosisDetails(props: {
readonly diagnosis: ConnectionDiagnosis;
}) {
const { diagnosis } = props;
const { moreInfoUrl } = diagnosis;
return (
<View className="gap-3">
<View className="gap-0.5">
Expand Down Expand Up @@ -87,6 +88,14 @@ export function ConnectionDiagnosisDetails(props: {
});
}}
/>
{moreInfoUrl ? (
<Button
label="More info"
icon="safari"
tone="secondary"
onPress={() => void Linking.openURL(moreInfoUrl).catch(() => undefined)}
/>
) : null}
</View>
);
}
117 changes: 109 additions & 8 deletions apps/server/src/cli/connect.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import {
AuthRelayReadScope,
AuthRelayWriteScope,
type AuthEnvironmentScope,
type EnvironmentConnectReachability,
EnvironmentHttpApi,
formatConnectReachable,
type RelayClientInstallProgressEvent,
type RelayClientInstallProgressStage,
} from "@t3tools/contracts";
Expand All @@ -9,6 +13,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess";
import * as RelayClient from "@t3tools/shared/relayClient";
import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
import * as Cause from "effect/Cause";
import * as Clock from "effect/Clock";
import * as Config from "effect/Config";
import * as Console from "effect/Console";
import * as Crypto from "effect/Crypto";
Expand All @@ -29,6 +34,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as BootService from "../cloud/bootService.ts";
import * as CliState from "../cloud/CliState.ts";
import * as CliTokenManager from "../cloud/CliTokenManager.ts";
import { formatReachabilityFailure } from "../cloud/ConnectReachability.ts";
import { filterRelayResponse } from "../cloud/relayResponse.ts";
import {
CLOUD_LINK_REVOKED,
Expand Down Expand Up @@ -142,6 +148,9 @@ interface CloudCliStatus {
readonly relayUrl: string | null;
readonly publishAgentActivity: boolean;
readonly relayClient: RelayClient.RelayClientStatus;
readonly linkMode: CliState.CliDesiredLinkMode;
/** `null` when no server answered the live check, or none ran because no tunnel is wanted. */
readonly reachability: EnvironmentConnectReachability | null;
}

function formatRelayClientStatus(executable: RelayClient.RelayClientStatus): ReadonlyArray<string> {
Expand Down Expand Up @@ -199,8 +208,19 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b
` Relay: ${status.relayUrl ?? "not provisioned"}`,
` Publish agent activity: ${status.publishAgentActivity ? "enabled" : "disabled"}`,
...formatRelayClientStatus(status.relayClient),
"",
"This is saved setup, not a live connection check. Check the background service with `t3 service status`.",
...(!status.desired || status.linkMode !== "managed"
? []
: status.reachability === null
? [
"",
"No running server answered, so reachability was not checked. Check the background service with `t3 service status`.",
]
: status.reachability.status === "reachable"
? [
"",
`✓ Other devices can reach this machine · ${formatConnectReachable(status.reachability)}`,
]
: ["", formatReachabilityFailure(status.reachability)]),
...(nextStep ? ["", `Next: ${nextStep}`] : []),
].join("\n");
}
Expand Down Expand Up @@ -261,11 +281,12 @@ export const acquireRelayClientForLink = Effect.fn("cloud.cli.acquire_relay_clie

const withCloudCliSessionToken = <A, E, R>(
environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"],
scope: AuthEnvironmentScope,
run: (token: string) => Effect.Effect<A, E, R>,
) =>
Effect.acquireUseRelease(
environmentAuth.issueSession({
scopes: [AuthRelayWriteScope],
scopes: [scope],
subject: "cloud-cli",
label: "t3 connect cli",
}),
Expand All @@ -287,7 +308,7 @@ const runLiveCloudUnlink = Effect.fn("cloud.cli.run_live_unlink")(function* () {

const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
const result = yield* Effect.exit(
withCloudCliSessionToken(environmentAuth, (token) =>
withCloudCliSessionToken(environmentAuth, AuthRelayWriteScope, (token) =>
HttpApiClient.make(EnvironmentHttpApi, {
baseUrl: runtimeState.value.origin,
}).pipe(
Expand All @@ -303,6 +324,73 @@ const runLiveCloudUnlink = Effect.fn("cloud.cli.run_live_unlink")(function* () {
: ({ status: "failed", cause: result.cause } satisfies LiveCloudActionResult);
});

// One check probes the endpoint, the tunnel edge, and Tailscale, each with its own timeout.
const CLOUD_CLI_REACHABILITY_TIMEOUT = Duration.seconds(30);
const CLOUD_CLI_REACHABILITY_WAIT = Duration.seconds(60);
const CLOUD_CLI_REACHABILITY_RETRY = Duration.seconds(5);

/** Asks the running server to check T3 Connect now. `null` when no server answers. */
const checkLiveReachability = Effect.fn("cloud.cli.check_live_reachability")(function* () {
const config = yield* ServerConfig.ServerConfig;
const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
if (Option.isNone(runtimeState)) return null;
const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
return yield* withCloudCliSessionToken(environmentAuth, AuthRelayReadScope, (token) =>
HttpApiClient.make(EnvironmentHttpApi, { baseUrl: runtimeState.value.origin }).pipe(
Effect.flatMap((client) =>
client.connect.checkReachability({ headers: { authorization: `Bearer ${token}` } }),
),
Effect.timeout(CLOUD_CLI_REACHABILITY_TIMEOUT),
),
).pipe(Effect.orElseSucceed(() => null));
});

function formatAlertBox(text: string): string {
const lines = text.split("\n");
const width = Math.max(...lines.map((line) => line.length));
const color = process.stdout.isTTY === true && !process.env.NO_COLOR;
const red = (line: string) => (color ? `\x1b[1;31m${line}\x1b[0m` : line);
return [
`┌${"─".repeat(width + 2)}┐`,
...lines.map((line) => `│ ${line.padEnd(width)} │`),
`└${"─".repeat(width + 2)}┘`,
]
.map(red)
.join("\n");
}

/**
* Waits for the server that `t3 connect` just started to prove other devices
* can reach it, so a broken tunnel fails setup instead of looking green.
*/
const waitForReachability = Effect.fn("cloud.cli.wait_for_reachability")(function* () {
yield* Console.log("\nChecking that other devices can reach this machine...");
const deadline =
(yield* Clock.currentTimeMillis) + Duration.toMillis(CLOUD_CLI_REACHABILITY_WAIT);
let last: EnvironmentConnectReachability | null = null;
while (true) {
last = (yield* checkLiveReachability()) ?? last;
if (last?.status === "reachable") {
yield* Console.log(
`✓ Other devices can reach this machine · ${formatConnectReachable(last)}`,
);
return;
}
if ((yield* Clock.currentTimeMillis) >= deadline) break;
yield* Effect.sleep(CLOUD_CLI_REACHABILITY_RETRY);
}
yield* Console.error(
formatAlertBox(
last === null
? "✕ Could not check T3 Connect\n The T3 server on this machine did not answer.\n Fix: run `t3 service status`, then `t3 connect status`."
: formatReachabilityFailure(last),
),
);
yield* Effect.sync(() => {
process.exitCode = 1;
});
});

type RelayUnlinkResult =
| { readonly status: "not-authenticated" }
| { readonly status: "revoked" }
Expand Down Expand Up @@ -551,7 +639,9 @@ const connectStatusCommand = Command.make("status", {
...projectLocationFlags,
json: jsonFlag,
}).pipe(
Command.withDescription("Show persisted T3 Connect and relay client state."),
Command.withDescription(
"Show T3 Connect setup and check that other devices can reach this machine.",
),
Command.withHandler((flags) =>
runCloudCommand(
flags,
Expand All @@ -567,6 +657,7 @@ const connectStatusCommand = Command.make("status", {
publishAgentActivity,
revoked,
executable,
linkMode,
] = yield* Effect.all(
[
CliState.readCliDesiredCloudLink,
Expand All @@ -576,9 +667,13 @@ const connectStatusCommand = Command.make("status", {
secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
secrets.get(CLOUD_LINK_REVOKED),
relayClient.resolve,
CliState.readCliDesiredLinkMode,
],
{ concurrency: "unbounded" },
);
// A publish-only link has no tunnel for other devices to reach.
const reachability =
desired && linkMode === "managed" ? yield* checkLiveReachability() : null;
const status: CloudCliStatus = {
desired,
authenticated,
Expand All @@ -590,6 +685,8 @@ const connectStatusCommand = Command.make("status", {
Option.isSome(publishAgentActivity) ? bytesToString(publishAgentActivity.value) : null,
),
relayClient: executable,
linkMode,
reachability,
};
yield* Console.log(formatCloudStatus(status, { json: flags.json }));
}),
Expand Down Expand Up @@ -703,7 +800,7 @@ export const reportServiceOnboarding = Effect.fn("cli.connect.report_service_onb
}
const serveCommand = yield* resolveCliCommand("serve");
yield* Console.log(
`\nNext\n Start the server with \`${serveCommand}\` to make this machine reachable.`,
`\nNext\n Start the server with \`${serveCommand}\` to make this machine reachable.\n It checks that other devices can reach this machine when it starts.`,
);
},
);
Expand All @@ -724,9 +821,13 @@ export const connectCommand = Command.make("connect", {
}
yield* Console.log(`✓ Authorized${connectedAs(linked.identity)}`);

yield* reportServiceOnboarding(
yield* CliService.recoverServiceOnboardingOffer(CliService.offerServiceDuringOnboarding),
const outcome = yield* CliService.recoverServiceOnboardingOffer(
CliService.offerServiceDuringOnboarding,
);
yield* reportServiceOnboarding(outcome);
if (outcome !== "skipped") {
yield* waitForReachability();
}
}),
),
),
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/cloud/CloudLink.lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
RELAY_URL_SECRET,
} from "./config.ts";
import * as CloudLink from "./CloudLink.ts";
import * as ConnectReachability from "./ConnectReachability.ts";
import { consumeCloudReplayGuards } from "./CloudLink.ts";
import { pendingServiceUpdateExists } from "./updateHandoff.ts";
import {
Expand Down Expand Up @@ -154,6 +155,13 @@ describe("reconcileDesiredCloudLink", () => {
expect(error._tag).toBe("CloudLinkAuthorizationMissingError");
}).pipe(
Effect.provide(CloudLink.layer),
Effect.provideService(
ConnectReachability.ConnectReachability,
ConnectReachability.ConnectReachability.of({
check: Effect.die("unused"),
latest: Effect.succeed(Option.none()),
}),
),
Effect.provideService(HttpServer.HttpServer, idleHttpServer),
Effect.provideService(
ServerConfigModule.ServerConfig,
Expand All @@ -177,6 +185,8 @@ describe("reconcileDesiredCloudLink", () => {
recoveryRequests: Stream.empty,
requestRecovery: () => Effect.void,
tunnelConnected: Stream.empty,
inspectConnector: Effect.succeed(null),
readinessChanged: Stream.empty,
withLinkStateLock: (effect) => effect,
} satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]),
),
Expand Down Expand Up @@ -276,6 +286,13 @@ describe("releaseManagedTunnelOnShutdown", () => {
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.provide(CloudLink.layer),
Effect.provideService(
ConnectReachability.ConnectReachability,
ConnectReachability.ConnectReachability.of({
check: Effect.die("unused"),
latest: Effect.succeed(Option.none()),
}),
),
Effect.provideService(HttpServer.HttpServer, {
...idleHttpServer,
address: harness.address ?? idleHttpServer.address,
Expand Down Expand Up @@ -308,6 +325,8 @@ describe("releaseManagedTunnelOnShutdown", () => {
recoveryRequests: Stream.empty,
requestRecovery: () => Effect.void,
tunnelConnected: Stream.empty,
inspectConnector: Effect.succeed(null),
readinessChanged: Stream.empty,
withLinkStateLock: (effect) => effect,
}),
),
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/cloud/CloudLink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts";
import * as CliTokenManager from "./CliTokenManager.ts";
import * as CloudLink from "./CloudLink.ts";
import * as ConnectReachability from "./ConnectReachability.ts";
import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
import {
HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET,
Expand Down Expand Up @@ -94,6 +95,9 @@ const withService = <A, E>(
}),
Layer.mock(AgentAwarenessRelay.AgentAwarenessRelay)({ requestCatchUp: () => Effect.void }),
Layer.mock(ManagedEndpointRuntime.CloudManagedEndpointRuntime)({}),
Layer.mock(ConnectReachability.ConnectReachability)({
latest: Effect.succeed(Option.none()),
}),
Layer.mock(EnvironmentAuth.EnvironmentAuth)({}),
Layer.mock(CliTokenManager.CloudCliTokenManager)({}),
Layer.mock(HttpServer.HttpServer)({
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/cloud/CloudLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import {
RELAY_URL_SECRET,
} from "./config.ts";
import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
import * as ConnectReachability from "./ConnectReachability.ts";
import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
import { relayUrlConfig } from "./publicConfig.ts";
import {
Expand Down Expand Up @@ -529,6 +530,7 @@ const make = Effect.gen(function* () {
const secrets = yield* ServerSecretStore.ServerSecretStore;
const environment = yield* ServerEnvironment.ServerEnvironment;
const endpointRuntime = yield* ManagedEndpointRuntime.CloudManagedEndpointRuntime;
const reachability = yield* ConnectReachability.ConnectReachability;
const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
const cliTokenManager = yield* CliTokenManager.CloudCliTokenManager;
const httpClient = yield* HttpClient.HttpClient;
Expand Down Expand Up @@ -1314,6 +1316,7 @@ const make = Effect.gen(function* () {
publishAgentActivity,
holdWebhooks,
revoked,
latestReachability,
] = yield* Effect.all(
[
secrets.get(CLOUD_LINKED_USER_ID),
Expand All @@ -1323,8 +1326,9 @@ const make = Effect.gen(function* () {
secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET),
secrets.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET),
secrets.get(CLOUD_LINK_REVOKED),
reachability.latest,
],
{ concurrency: 7 },
{ concurrency: 8 },
);
return {
linked: Option.isSome(cloudUserId),
Expand All @@ -1338,6 +1342,7 @@ const make = Effect.gen(function* () {
: false,
holdWebhooksWhileOffline:
Option.isSome(holdWebhooks) && bytesToString(holdWebhooks.value) === "true",
...(Option.isSome(latestReachability) ? { reachability: latestReachability.value } : {}),
} satisfies EnvironmentCloudLinkStateResult;
});

Expand Down
Loading
Loading