| author | |
| committer | |
| log | 8e018ff91f64d6caedc029e498eca446ff0fad20 |
| tree | ec536a425a92e0f0bd6d0464962f8fa5b25198c2 |
| parent | b037b9a535ff30f3f390da682dabeffe4c439044 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>16 files changed, 388 insertions(+), 45 deletions(-)
apps/server/src/cli/connect.ts+43-22| ... | @@ -31,6 +31,7 @@ import * as CliState from "../cloud/CliState.ts"; | ... | @@ -31,6 +31,7 @@ import * as CliState from "../cloud/CliState.ts"; |
| 31 | import * as CliTokenManager from "../cloud/CliTokenManager.ts"; | 31 | import * as CliTokenManager from "../cloud/CliTokenManager.ts"; |
| 32 | import { filterRelayResponse } from "../cloud/relayResponse.ts"; | 32 | import { filterRelayResponse } from "../cloud/relayResponse.ts"; |
| 33 | import { | 33 | import { |
| 34 | CLOUD_LINK_REVOKED, | ||
| 34 | CLOUD_LINKED_USER_ID, | 35 | CLOUD_LINKED_USER_ID, |
| 35 | isAgentActivityPublishingEnabledValue, | 36 | isAgentActivityPublishingEnabledValue, |
| 36 | PUBLISH_AGENT_ACTIVITY_SECRET, | 37 | PUBLISH_AGENT_ACTIVITY_SECRET, |
| ... | @@ -140,6 +141,8 @@ interface CloudCliStatus { | ... | @@ -140,6 +141,8 @@ interface CloudCliStatus { |
| 140 | readonly desired: boolean; | 141 | readonly desired: boolean; |
| 141 | readonly authenticated: boolean; | 142 | readonly authenticated: boolean; |
| 142 | readonly linked: boolean; | 143 | readonly linked: boolean; |
| 144 | // The relay rejected the stored link, usually after a Deregister. | ||
| 145 | readonly revoked: boolean; | ||
| 143 | readonly cloudUserId: string | null; | 146 | readonly cloudUserId: string | null; |
| 144 | readonly relayUrl: string | null; | 147 | readonly relayUrl: string | null; |
| 145 | readonly publishAgentActivity: boolean; | 148 | readonly publishAgentActivity: boolean; |
| ... | @@ -176,18 +179,22 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b | ... | @@ -176,18 +179,22 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b |
| 176 | return JSON.stringify(status, null, 2); | 179 | return JSON.stringify(status, null, 2); |
| 177 | } | 180 | } |
| 178 | 181 | ||
| 179 | const provisioned = status.linked | 182 | const provisioned = status.revoked |
| 180 | ? "provisioned" | 183 | ? "removed from T3 Connect" |
| 181 | : status.desired && status.authenticated | 184 | : status.linked |
| 182 | ? "pending server startup" | 185 | ? "provisioned" |
| 183 | : "not provisioned"; | 186 | : status.desired && status.authenticated |
| 187 | ? "pending server startup" | ||
| 188 | : "not provisioned"; | ||
| 184 | const nextStep = !status.authenticated | 189 | const nextStep = !status.authenticated |
| 185 | ? "Run `t3 connect link` to authorize and enable T3 Connect." | 190 | ? "Run `t3 connect link` to authorize and enable T3 Connect." |
| 186 | : !status.desired | 191 | : status.revoked |
| 187 | ? "Run `t3 connect link` to enable T3 Connect." | 192 | ? "Run `t3 connect link` to link this environment again." |
| 188 | : !status.linked | 193 | : !status.desired |
| 189 | ? "Start T3 to provision the environment link and launch its managed tunnel." | 194 | ? "Run `t3 connect link` to enable T3 Connect." |
| 190 | : undefined; | 195 | : !status.linked |
| 196 | ? "Start T3 to provision the environment link and launch its managed tunnel." | ||
| 197 | : undefined; | ||
| 191 | 198 | ||
| 192 | return [ | 199 | return [ |
| 193 | "T3 Connect", | 200 | "T3 Connect", |
| ... | @@ -477,6 +484,11 @@ const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(functi | ... | @@ -477,6 +484,11 @@ const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(functi |
| 477 | 484 | ||
| 478 | const identity = yield* authorizeCli(options); | 485 | const identity = yield* authorizeCli(options); |
| 479 | yield* CliState.setCliDesiredCloudLink(true, publishOnly ? "publish_only" : "managed"); | 486 | yield* CliState.setCliDesiredCloudLink(true, publishOnly ? "publish_only" : "managed"); |
| 487 | if (yield* CliState.dropRevokedCloudLink) { | ||
| 488 | yield* Console.log( | ||
| 489 | "Removed the link T3 Connect no longer accepts. The next start links this environment again.", | ||
| 490 | ); | ||
| 491 | } | ||
| 480 | if (publishOnly) { | 492 | if (publishOnly) { |
| 481 | const secrets = yield* ServerSecretStore.ServerSecretStore; | 493 | const secrets = yield* ServerSecretStore.ServerSecretStore; |
| 482 | yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true")); | 494 | yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true")); |
| ... | @@ -543,22 +555,31 @@ const connectStatusCommand = Command.make("status", { | ... | @@ -543,22 +555,31 @@ const connectStatusCommand = Command.make("status", { |
| 543 | const secrets = yield* ServerSecretStore.ServerSecretStore; | 555 | const secrets = yield* ServerSecretStore.ServerSecretStore; |
| 544 | const relayClient = yield* RelayClient.RelayClient; | 556 | const relayClient = yield* RelayClient.RelayClient; |
| 545 | const tokens = yield* CliTokenManager.CloudCliTokenManager; | 557 | const tokens = yield* CliTokenManager.CloudCliTokenManager; |
| 546 | const [desired, authenticated, cloudUserId, relayUrl, publishAgentActivity, executable] = | 558 | const [ |
| 547 | yield* Effect.all( | 559 | desired, |
| 548 | [ | 560 | authenticated, |
| 549 | CliState.readCliDesiredCloudLink, | 561 | cloudUserId, |
| 550 | tokens.hasCredential, | 562 | relayUrl, |
| 551 | secrets.get(CLOUD_LINKED_USER_ID), | 563 | publishAgentActivity, |
| 552 | secrets.get(RELAY_URL_SECRET), | 564 | revoked, |
| 553 | secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), | 565 | executable, |
| 554 | relayClient.resolve, | 566 | ] = yield* Effect.all( |
| 555 | ], | 567 | [ |
| 556 | { concurrency: "unbounded" }, | 568 | CliState.readCliDesiredCloudLink, |
| 557 | ); | 569 | tokens.hasCredential, |
| 570 | secrets.get(CLOUD_LINKED_USER_ID), | ||
| 571 | secrets.get(RELAY_URL_SECRET), | ||
| 572 | secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), | ||
| 573 | secrets.get(CLOUD_LINK_REVOKED), | ||
| 574 | relayClient.resolve, | ||
| 575 | ], | ||
| 576 | { concurrency: "unbounded" }, | ||
| 577 | ); | ||
| 558 | const status: CloudCliStatus = { | 578 | const status: CloudCliStatus = { |
| 559 | desired, | 579 | desired, |
| 560 | authenticated, | 580 | authenticated, |
| 561 | linked: Option.isSome(cloudUserId), | 581 | linked: Option.isSome(cloudUserId), |
| 582 | revoked: Option.isSome(cloudUserId) && Option.isSome(revoked), | ||
| 562 | cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, | 583 | cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, |
| 563 | relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, | 584 | relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, |
| 564 | publishAgentActivity: isAgentActivityPublishingEnabledValue( | 585 | publishAgentActivity: isAgentActivityPublishingEnabledValue( |
apps/server/src/cloud/CliState.test.ts+32| ... | @@ -10,6 +10,7 @@ import * as CliState from "./CliState.ts"; | ... | @@ -10,6 +10,7 @@ import * as CliState from "./CliState.ts"; |
| 10 | import { | 10 | import { |
| 11 | CLOUD_ENDPOINT_CONFIRMED_ORIGIN, | 11 | CLOUD_ENDPOINT_CONFIRMED_ORIGIN, |
| 12 | CLOUD_ENDPOINT_RUNTIME_CONFIG, | 12 | CLOUD_ENDPOINT_RUNTIME_CONFIG, |
| 13 | CLOUD_LINK_REVOKED, | ||
| 13 | CLOUD_LINKED_USER_ID, | 14 | CLOUD_LINKED_USER_ID, |
| 14 | CLOUD_MINT_PUBLIC_KEY, | 15 | CLOUD_MINT_PUBLIC_KEY, |
| 15 | PUBLISH_AGENT_ACTIVITY_SECRET, | 16 | PUBLISH_AGENT_ACTIVITY_SECRET, |
| ... | @@ -59,6 +60,37 @@ it.layer(NodeServices.layer)("CliState", (it) => { | ... | @@ -59,6 +60,37 @@ it.layer(NodeServices.layer)("CliState", (it) => { |
| 59 | }).pipe(Effect.provide(layerTest())), | 60 | }).pipe(Effect.provide(layerTest())), |
| 60 | ); | 61 | ); |
| 61 | 62 | ||
| 63 | it.effect("drops only a revoked link, keeping the sign-in and preferences", () => | ||
| 64 | Effect.gen(function* () { | ||
| 65 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 66 | const dropped = [ | ||
| 67 | CLOUD_LINKED_USER_ID, | ||
| 68 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | ||
| 69 | CLOUD_MINT_PUBLIC_KEY, | ||
| 70 | CLOUD_ENDPOINT_RUNTIME_CONFIG, | ||
| 71 | CLOUD_ENDPOINT_CONFIRMED_ORIGIN, | ||
| 72 | ]; | ||
| 73 | const kept = [RELAY_URL_SECRET, PUBLISH_AGENT_ACTIVITY_SECRET]; | ||
| 74 | for (const name of [...dropped, ...kept]) { | ||
| 75 | yield* secrets.set(name, new TextEncoder().encode(name)); | ||
| 76 | } | ||
| 77 | yield* CliState.setCliDesiredCloudLink(true); | ||
| 78 | |||
| 79 | assert.isFalse(yield* CliState.dropRevokedCloudLink); | ||
| 80 | assert.isTrue(Option.isSome(yield* secrets.get(CLOUD_LINKED_USER_ID))); | ||
| 81 | |||
| 82 | yield* secrets.set(CLOUD_LINK_REVOKED, new TextEncoder().encode("true")); | ||
| 83 | assert.isTrue(yield* CliState.dropRevokedCloudLink); | ||
| 84 | for (const name of [...dropped, CLOUD_LINK_REVOKED]) { | ||
| 85 | assert.isTrue(Option.isNone(yield* secrets.get(name))); | ||
| 86 | } | ||
| 87 | for (const name of kept) { | ||
| 88 | assert.isTrue(Option.isSome(yield* secrets.get(name))); | ||
| 89 | } | ||
| 90 | assert.isTrue(yield* CliState.readCliDesiredCloudLink); | ||
| 91 | }).pipe(Effect.provide(layerTest())), | ||
| 92 | ); | ||
| 93 | |||
| 62 | it.effect("round-trips the desired link mode and defaults legacy links to managed", () => | 94 | it.effect("round-trips the desired link mode and defaults legacy links to managed", () => |
| 63 | Effect.gen(function* () { | 95 | Effect.gen(function* () { |
| 64 | const secrets = yield* ServerSecretStore.ServerSecretStore; | 96 | const secrets = yield* ServerSecretStore.ServerSecretStore; |
apps/server/src/cloud/CliState.ts+27| ... | @@ -5,6 +5,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ... | @@ -5,6 +5,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 5 | import { | 5 | import { |
| 6 | CLOUD_ENDPOINT_CONFIRMED_ORIGIN, | 6 | CLOUD_ENDPOINT_CONFIRMED_ORIGIN, |
| 7 | CLOUD_ENDPOINT_RUNTIME_CONFIG, | 7 | CLOUD_ENDPOINT_RUNTIME_CONFIG, |
| 8 | CLOUD_LINK_REVOKED, | ||
| 8 | CLOUD_LINKED_USER_ID, | 9 | CLOUD_LINKED_USER_ID, |
| 9 | CLOUD_MINT_PUBLIC_KEY, | 10 | CLOUD_MINT_PUBLIC_KEY, |
| 10 | PUBLISH_AGENT_ACTIVITY_SECRET, | 11 | PUBLISH_AGENT_ACTIVITY_SECRET, |
| ... | @@ -57,6 +58,31 @@ export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(f | ... | @@ -57,6 +58,31 @@ export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(f |
| 57 | } | 58 | } |
| 58 | }); | 59 | }); |
| 59 | 60 | ||
| 61 | /** | ||
| 62 | * Drops a link the relay rejected, keeping the sign-in and preferences, so the | ||
| 63 | * next start links this environment again. `t3 connect link` calls it: the | ||
| 64 | * server never relinks a revoked link on its own. Returns whether it dropped one. | ||
| 65 | */ | ||
| 66 | export const dropRevokedCloudLink = Effect.gen(function* () { | ||
| 67 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 68 | if (Option.isNone(yield* secrets.get(CLOUD_LINK_REVOKED))) { | ||
| 69 | return false; | ||
| 70 | } | ||
| 71 | yield* Effect.all( | ||
| 72 | [ | ||
| 73 | secrets.remove(CLOUD_LINKED_USER_ID), | ||
| 74 | secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), | ||
| 75 | secrets.remove(CLOUD_MINT_PUBLIC_KEY), | ||
| 76 | secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), | ||
| 77 | secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), | ||
| 78 | ], | ||
| 79 | { concurrency: "unbounded" }, | ||
| 80 | ); | ||
| 81 | // Last, so a failed removal above leaves the mark for the next attempt. | ||
| 82 | yield* secrets.remove(CLOUD_LINK_REVOKED); | ||
| 83 | return true; | ||
| 84 | }); | ||
| 85 | |||
| 60 | export const clearPersistedCloudLink = Effect.gen(function* () { | 86 | export const clearPersistedCloudLink = Effect.gen(function* () { |
| 61 | const secrets = yield* ServerSecretStore.ServerSecretStore; | 87 | const secrets = yield* ServerSecretStore.ServerSecretStore; |
| 62 | yield* Effect.all( | 88 | yield* Effect.all( |
| ... | @@ -70,6 +96,7 @@ export const clearPersistedCloudLink = Effect.gen(function* () { | ... | @@ -70,6 +96,7 @@ export const clearPersistedCloudLink = Effect.gen(function* () { |
| 70 | secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), | 96 | secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), |
| 71 | secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), | 97 | secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), |
| 72 | secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), | 98 | secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), |
| 99 | secrets.remove(CLOUD_LINK_REVOKED), | ||
| 73 | ], | 100 | ], |
| 74 | { concurrency: "unbounded" }, | 101 | { concurrency: "unbounded" }, |
| 75 | ); | 102 | ); |
apps/server/src/cloud/CloudLink.lifecycle.test.ts+151-1| ... | @@ -34,6 +34,7 @@ import { RelayManagedEndpointRecoveryRegistrationRequest } from "@t3tools/contra | ... | @@ -34,6 +34,7 @@ import { RelayManagedEndpointRecoveryRegistrationRequest } from "@t3tools/contra |
| 34 | import { | 34 | import { |
| 35 | CLOUD_ENDPOINT_CONFIRMED_ORIGIN, | 35 | CLOUD_ENDPOINT_CONFIRMED_ORIGIN, |
| 36 | CLOUD_ENDPOINT_RUNTIME_CONFIG, | 36 | CLOUD_ENDPOINT_RUNTIME_CONFIG, |
| 37 | CLOUD_LINK_REVOKED, | ||
| 37 | CLOUD_LINKED_USER_ID, | 38 | CLOUD_LINKED_USER_ID, |
| 38 | decodeConfirmedOrigin, | 39 | decodeConfirmedOrigin, |
| 39 | decodeRuntimeConfig, | 40 | decodeRuntimeConfig, |
| ... | @@ -694,6 +695,115 @@ describe("releaseManagedTunnelOnShutdown", () => { | ... | @@ -694,6 +695,115 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 694 | ); | 695 | ); |
| 695 | }); | 696 | }); |
| 696 | 697 | ||
| 698 | it.effect("marks a link the relay rejects as revoked without dropping or replacing it", () => { | ||
| 699 | const { store, values } = makeMemorySecretStore([ | ||
| 700 | [ | ||
| 701 | CLOUD_ENDPOINT_RUNTIME_CONFIG, | ||
| 702 | '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}', | ||
| 703 | ], | ||
| 704 | [RELAY_URL_SECRET, "https://relay.example.test"], | ||
| 705 | [CLOUD_LINKED_USER_ID, "user-123"], | ||
| 706 | [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], | ||
| 707 | [CLOUD_CLI_DESIRED_LINK_SECRET, "managed"], | ||
| 708 | ]); | ||
| 709 | const applyConfigCalls: Array<unknown> = []; | ||
| 710 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; | ||
| 711 | |||
| 712 | return Effect.gen(function* () { | ||
| 713 | const link = yield* CloudLink.CloudLink; | ||
| 714 | expect(yield* link.registerManagedTunnelRecovery("http://127.0.0.1:3773")).toEqual({ | ||
| 715 | status: "revoked", | ||
| 716 | }); | ||
| 717 | expect((yield* link.linkState()).linkRevoked).toBe(true); | ||
| 718 | // One registration request, no relink, and nothing started or removed. | ||
| 719 | expect(requests).toHaveLength(1); | ||
| 720 | expect(applyConfigCalls).toEqual([]); | ||
| 721 | expect(values.has(RELAY_ENVIRONMENT_CREDENTIAL_SECRET)).toBe(true); | ||
| 722 | expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); | ||
| 723 | }).pipe( | ||
| 724 | provideReleaseHarness({ | ||
| 725 | store, | ||
| 726 | applyConfigCalls, | ||
| 727 | requests, | ||
| 728 | respond: () => | ||
| 729 | Response.json( | ||
| 730 | { | ||
| 731 | _tag: "RelayAuthInvalidError", | ||
| 732 | code: "auth_invalid", | ||
| 733 | reason: "not_authorized", | ||
| 734 | traceId: "trace-revoked", | ||
| 735 | }, | ||
| 736 | { status: 401 }, | ||
| 737 | ), | ||
| 738 | }), | ||
| 739 | ); | ||
| 740 | }); | ||
| 741 | |||
| 742 | it.effect("does not mark a link that was replaced while its rejection was in flight", () => { | ||
| 743 | const { store, values } = makeMemorySecretStore([ | ||
| 744 | [ | ||
| 745 | CLOUD_ENDPOINT_RUNTIME_CONFIG, | ||
| 746 | '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}', | ||
| 747 | ], | ||
| 748 | [RELAY_URL_SECRET, "https://relay.example.test"], | ||
| 749 | [CLOUD_LINKED_USER_ID, "user-123"], | ||
| 750 | [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "old-credential"], | ||
| 751 | ]); | ||
| 752 | |||
| 753 | return Effect.gen(function* () { | ||
| 754 | const link = yield* CloudLink.CloudLink; | ||
| 755 | expect(yield* link.registerManagedTunnelRecovery("http://127.0.0.1:3773")).toEqual({ | ||
| 756 | status: "superseded", | ||
| 757 | }); | ||
| 758 | expect(values.has(CLOUD_LINK_REVOKED)).toBe(false); | ||
| 759 | }).pipe( | ||
| 760 | provideReleaseHarness({ | ||
| 761 | store, | ||
| 762 | applyConfigCalls: [], | ||
| 763 | requests: [], | ||
| 764 | respond: () => { | ||
| 765 | values.set( | ||
| 766 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | ||
| 767 | new TextEncoder().encode("replacement-credential"), | ||
| 768 | ); | ||
| 769 | return Response.json( | ||
| 770 | { | ||
| 771 | _tag: "RelayAuthInvalidError", | ||
| 772 | code: "auth_invalid", | ||
| 773 | reason: "not_authorized", | ||
| 774 | traceId: "trace-stale", | ||
| 775 | }, | ||
| 776 | { status: 401 }, | ||
| 777 | ); | ||
| 778 | }, | ||
| 779 | }), | ||
| 780 | ); | ||
| 781 | }); | ||
| 782 | |||
| 783 | it.effect("clears the revoked mark when the environment is linked again", () => { | ||
| 784 | const { store } = makeMemorySecretStore([ | ||
| 785 | [RELAY_URL_SECRET, "https://relay.example.test"], | ||
| 786 | [CLOUD_LINKED_USER_ID, "user-123"], | ||
| 787 | [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "revoked-credential"], | ||
| 788 | [CLOUD_LINK_REVOKED, "true"], | ||
| 789 | ]); | ||
| 790 | const cloudMintPublicKey = | ||
| 791 | "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAqZ2u2LjMoAyG2QTHXmITJ9w6RzZtVUAv394XCcPV0zE=\n-----END PUBLIC KEY-----"; | ||
| 792 | |||
| 793 | return Effect.gen(function* () { | ||
| 794 | const link = yield* CloudLink.CloudLink; | ||
| 795 | expect((yield* link.linkState()).linkRevoked).toBe(true); | ||
| 796 | yield* link.applyRelayConfig({ | ||
| 797 | relayUrl: "https://relay.example.test", | ||
| 798 | cloudUserId: "user-123", | ||
| 799 | environmentCredential: "fresh-credential", | ||
| 800 | cloudMintPublicKey, | ||
| 801 | endpointRuntime: null, | ||
| 802 | }); | ||
| 803 | expect(yield* link.linkState()).toMatchObject({ linked: true, linkRevoked: false }); | ||
| 804 | }).pipe(provideReleaseHarness({ store, applyConfigCalls: [], requests: [] })); | ||
| 805 | }); | ||
| 806 | |||
| 697 | it.effect("reconciles a changed port after a relay outage outlasts the startup fallback", () => { | 807 | it.effect("reconciles a changed port after a relay outage outlasts the startup fallback", () => { |
| 698 | const config = { | 808 | const config = { |
| 699 | providerKind: "cloudflare_tunnel" as const, | 809 | providerKind: "cloudflare_tunnel" as const, |
| ... | @@ -944,7 +1054,7 @@ describe("releaseManagedTunnelOnShutdown", () => { | ... | @@ -944,7 +1054,7 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 944 | }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); | 1054 | }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); |
| 945 | }); | 1055 | }); |
| 946 | 1056 | ||
| 947 | it.effect("recovers a web-linked tunnel with its environment credential", () => { | 1057 | it.effect("recovers a web-linked tunnel and clears its revoked mark", () => { |
| 948 | const oldConfig = | 1058 | const oldConfig = |
| 949 | '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}'; | 1059 | '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}'; |
| 950 | const nextConfig = { | 1060 | const nextConfig = { |
| ... | @@ -957,6 +1067,7 @@ describe("releaseManagedTunnelOnShutdown", () => { | ... | @@ -957,6 +1067,7 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 957 | [RELAY_URL_SECRET, "https://relay.example.test"], | 1067 | [RELAY_URL_SECRET, "https://relay.example.test"], |
| 958 | [CLOUD_LINKED_USER_ID, "user-123"], | 1068 | [CLOUD_LINKED_USER_ID, "user-123"], |
| 959 | [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], | 1069 | [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], |
| 1070 | [CLOUD_LINK_REVOKED, "true"], | ||
| 960 | ]); | 1071 | ]); |
| 961 | const applyConfigCalls: Array<unknown> = []; | 1072 | const applyConfigCalls: Array<unknown> = []; |
| 962 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; | 1073 | const requests: Array<HttpClientRequest.HttpClientRequest> = []; |
| ... | @@ -974,6 +1085,7 @@ describe("releaseManagedTunnelOnShutdown", () => { | ... | @@ -974,6 +1085,7 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 974 | decodeRuntimeConfig(new TextDecoder().decode(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG))), | 1085 | decodeRuntimeConfig(new TextDecoder().decode(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG))), |
| 975 | ), | 1086 | ), |
| 976 | ).toEqual(nextConfig); | 1087 | ).toEqual(nextConfig); |
| 1088 | expect(values.has(CLOUD_LINK_REVOKED)).toBe(false); | ||
| 977 | }).pipe( | 1089 | }).pipe( |
| 978 | provideReleaseHarness({ | 1090 | provideReleaseHarness({ |
| 979 | store, | 1091 | store, |
| ... | @@ -992,6 +1104,44 @@ describe("releaseManagedTunnelOnShutdown", () => { | ... | @@ -992,6 +1104,44 @@ describe("releaseManagedTunnelOnShutdown", () => { |
| 992 | ); | 1104 | ); |
| 993 | }); | 1105 | }); |
| 994 | 1106 | ||
| 1107 | it.effect("marks the link revoked when the relay rejects a running host's recovery", () => { | ||
| 1108 | const { store, values } = makeMemorySecretStore([ | ||
| 1109 | [ | ||
| 1110 | CLOUD_ENDPOINT_RUNTIME_CONFIG, | ||
| 1111 | '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}', | ||
| 1112 | ], | ||
| 1113 | [RELAY_URL_SECRET, "https://relay.example.test"], | ||
| 1114 | [CLOUD_LINKED_USER_ID, "user-123"], | ||
| 1115 | [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], | ||
| 1116 | ]); | ||
| 1117 | const applyConfigCalls: Array<unknown> = []; | ||
| 1118 | |||
| 1119 | return Effect.gen(function* () { | ||
| 1120 | const link = yield* CloudLink.CloudLink; | ||
| 1121 | const error = yield* Effect.flip(link.recoverManagedTunnel("http://127.0.0.1:3773")); | ||
| 1122 | expect(error).toMatchObject({ _tag: "RelayRequestError", rejection: "unauthorized" }); | ||
| 1123 | expect((yield* link.linkState()).linkRevoked).toBe(true); | ||
| 1124 | expect(applyConfigCalls).toEqual([]); | ||
| 1125 | expect(values.has(RELAY_ENVIRONMENT_CREDENTIAL_SECRET)).toBe(true); | ||
| 1126 | }).pipe( | ||
| 1127 | provideReleaseHarness({ | ||
| 1128 | store, | ||
| 1129 | applyConfigCalls, | ||
| 1130 | requests: [], | ||
| 1131 | respond: () => | ||
| 1132 | Response.json( | ||
| 1133 | { | ||
| 1134 | _tag: "RelayAuthInvalidError", | ||
| 1135 | code: "auth_invalid", | ||
| 1136 | reason: "not_authorized", | ||
| 1137 | traceId: "trace-recovery-revoked", | ||
| 1138 | }, | ||
| 1139 | { status: 401 }, | ||
| 1140 | ), | ||
| 1141 | }), | ||
| 1142 | ); | ||
| 1143 | }); | ||
| 1144 | |||
| 995 | it.effect("allows managed tunnel provisioning to take longer than ten seconds", () => | 1145 | it.effect("allows managed tunnel provisioning to take longer than ten seconds", () => |
| 996 | Effect.gen(function* () { | 1146 | Effect.gen(function* () { |
| 997 | const oldConfig = | 1147 | const oldConfig = |
apps/server/src/cloud/CloudLink.ts+62-7| ... | @@ -85,6 +85,7 @@ import * as CliTokenManager from "./CliTokenManager.ts"; | ... | @@ -85,6 +85,7 @@ import * as CliTokenManager from "./CliTokenManager.ts"; |
| 85 | import { | 85 | import { |
| 86 | CLOUD_ENDPOINT_CONFIRMED_ORIGIN, | 86 | CLOUD_ENDPOINT_CONFIRMED_ORIGIN, |
| 87 | CLOUD_ENDPOINT_RUNTIME_CONFIG, | 87 | CLOUD_ENDPOINT_RUNTIME_CONFIG, |
| 88 | CLOUD_LINK_REVOKED, | ||
| 88 | CLOUD_LINKED_USER_ID, | 89 | CLOUD_LINKED_USER_ID, |
| 89 | CLOUD_MINT_PUBLIC_KEY, | 90 | CLOUD_MINT_PUBLIC_KEY, |
| 90 | decodeConfirmedOrigin, | 91 | decodeConfirmedOrigin, |
| ... | @@ -431,8 +432,10 @@ type CloudLinkBackgroundError = | ... | @@ -431,8 +432,10 @@ type CloudLinkBackgroundError = |
| 431 | | Schema.SchemaError | 432 | | Schema.SchemaError |
| 432 | | PlatformError.PlatformError; | 433 | | PlatformError.PlatformError; |
| 433 | 434 | ||
| 435 | // "revoked" means the relay rejected this environment's credential, as after | ||
| 436 | // the environment is deregistered from T3 Connect. | ||
| 434 | type ManagedTunnelRegistration = | 437 | type ManagedTunnelRegistration = |
| 435 | | { readonly status: "not_linked" | "superseded" } | 438 | | { readonly status: "not_linked" | "superseded" | "revoked" } |
| 436 | | { readonly status: "recovery_required"; readonly config: RelayManagedEndpointRuntimeConfig } | 439 | | { readonly status: "recovery_required"; readonly config: RelayManagedEndpointRuntimeConfig } |
| 437 | | { | 440 | | { |
| 438 | readonly status: "ready"; | 441 | readonly status: "ready"; |
| ... | @@ -838,6 +841,7 @@ const make = Effect.gen(function* () { | ... | @@ -838,6 +841,7 @@ const make = Effect.gen(function* () { |
| 838 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | 841 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, |
| 839 | stringToBytes(payload.environmentCredential), | 842 | stringToBytes(payload.environmentCredential), |
| 840 | ); | 843 | ); |
| 844 | yield* secrets.remove(CLOUD_LINK_REVOKED); | ||
| 841 | yield* secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey)); | 845 | yield* secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey)); |
| 842 | yield* awarenessRelay.requestCatchUp(); | 846 | yield* awarenessRelay.requestCatchUp(); |
| 843 | if (endpointRuntimeJson !== null) { | 847 | if (endpointRuntimeJson !== null) { |
| ... | @@ -1017,6 +1021,30 @@ const make = Effect.gen(function* () { | ... | @@ -1017,6 +1021,30 @@ const make = Effect.gen(function* () { |
| 1017 | ); | 1021 | ); |
| 1018 | }); | 1022 | }); |
| 1019 | 1023 | ||
| 1024 | /** | ||
| 1025 | * Marks the link revoked after the relay answered 401 to `credential`, usually | ||
| 1026 | * because the environment was deregistered. Retrying cannot fix that, and | ||
| 1027 | * relinking is the user's call, so only mark it. Nothing stored is removed: | ||
| 1028 | * the relay also answers 401 for a skewed clock, and the next accepted | ||
| 1029 | * registration clears the mark. Returns false, without marking, when a relink | ||
| 1030 | * replaced the credential while the rejected request was in flight. | ||
| 1031 | */ | ||
| 1032 | const markLinkRevoked = (credential: string, cause: RelayRequestError) => | ||
| 1033 | endpointRuntime.withLinkStateLock( | ||
| 1034 | Effect.gen(function* () { | ||
| 1035 | const current = yield* secrets.get(RELAY_ENVIRONMENT_CREDENTIAL_SECRET); | ||
| 1036 | if (Option.isNone(current) || bytesToString(current.value) !== credential) { | ||
| 1037 | return false; | ||
| 1038 | } | ||
| 1039 | yield* Effect.logWarning( | ||
| 1040 | "T3 Connect no longer accepts this environment's link. Link it again to restore remote access.", | ||
| 1041 | { cause }, | ||
| 1042 | ); | ||
| 1043 | yield* secrets.set(CLOUD_LINK_REVOKED, stringToBytes("true")); | ||
| 1044 | return true; | ||
| 1045 | }), | ||
| 1046 | ); | ||
| 1047 | |||
| 1020 | const registerManagedTunnelRecovery = Effect.fn( | 1048 | const registerManagedTunnelRecovery = Effect.fn( |
| 1021 | "environment.cloud.registerManagedCloudTunnelRecovery", | 1049 | "environment.cloud.registerManagedCloudTunnelRecovery", |
| 1022 | )(function* (localOrigin: string, options?: { readonly retryRuntimeFailures?: boolean }) { | 1050 | )(function* (localOrigin: string, options?: { readonly retryRuntimeFailures?: boolean }) { |
| ... | @@ -1059,9 +1087,10 @@ const make = Effect.gen(function* () { | ... | @@ -1059,9 +1087,10 @@ const make = Effect.gen(function* () { |
| 1059 | tunnelId: config.tunnelId, | 1087 | tunnelId: config.tunnelId, |
| 1060 | origin, | 1088 | origin, |
| 1061 | }); | 1089 | }); |
| 1090 | const credential = bytesToString(environmentCredential.value); | ||
| 1062 | const registered = yield* relayClientRequest({ | 1091 | const registered = yield* relayClientRequest({ |
| 1063 | url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel/recovery`, | 1092 | url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel/recovery`, |
| 1064 | token: bytesToString(environmentCredential.value), | 1093 | token: credential, |
| 1065 | payload: { | 1094 | payload: { |
| 1066 | cloudUserId: cloudUserIdValue, | 1095 | cloudUserId: cloudUserIdValue, |
| 1067 | tunnelId: config.tunnelId, | 1096 | tunnelId: config.tunnelId, |
| ... | @@ -1069,7 +1098,20 @@ const make = Effect.gen(function* () { | ... | @@ -1069,7 +1098,20 @@ const make = Effect.gen(function* () { |
| 1069 | proof, | 1098 | proof, |
| 1070 | }, | 1099 | }, |
| 1071 | schema: RelayManagedEndpointRecoveryRegistrationResponse, | 1100 | schema: RelayManagedEndpointRecoveryRegistrationResponse, |
| 1072 | }); | 1101 | }).pipe( |
| 1102 | Effect.catchTags({ | ||
| 1103 | RelayRequestError: (error) => | ||
| 1104 | error.rejection === "unauthorized" | ||
| 1105 | ? markLinkRevoked(credential, error).pipe( | ||
| 1106 | Effect.map((marked) => (marked ? ("revoked" as const) : ("superseded" as const))), | ||
| 1107 | ) | ||
| 1108 | : Effect.fail(error), | ||
| 1109 | }), | ||
| 1110 | ); | ||
| 1111 | if (registered === "revoked" || registered === "superseded") { | ||
| 1112 | return { status: registered }; | ||
| 1113 | } | ||
| 1114 | yield* secrets.remove(CLOUD_LINK_REVOKED); | ||
| 1073 | if (registered.status === "recovery_required") { | 1115 | if (registered.status === "recovery_required") { |
| 1074 | return { status: registered.status, config }; | 1116 | return { status: registered.status, config }; |
| 1075 | } | 1117 | } |
| ... | @@ -1134,9 +1176,10 @@ const make = Effect.gen(function* () { | ... | @@ -1134,9 +1176,10 @@ const make = Effect.gen(function* () { |
| 1134 | relayUrl: relayUrlValue, | 1176 | relayUrl: relayUrlValue, |
| 1135 | origin, | 1177 | origin, |
| 1136 | }); | 1178 | }); |
| 1179 | const credential = bytesToString(environmentCredential.value); | ||
| 1137 | const recovered = yield* relayClientRequest({ | 1180 | const recovered = yield* relayClientRequest({ |
| 1138 | url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel`, | 1181 | url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel`, |
| 1139 | token: bytesToString(environmentCredential.value), | 1182 | token: credential, |
| 1140 | payload: { | 1183 | payload: { |
| 1141 | cloudUserId: cloudUserIdValue, | 1184 | cloudUserId: cloudUserIdValue, |
| 1142 | origin, | 1185 | origin, |
| ... | @@ -1144,7 +1187,13 @@ const make = Effect.gen(function* () { | ... | @@ -1144,7 +1187,13 @@ const make = Effect.gen(function* () { |
| 1144 | }, | 1187 | }, |
| 1145 | schema: RelayManagedEndpointRecoveryResponse, | 1188 | schema: RelayManagedEndpointRecoveryResponse, |
| 1146 | timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT, | 1189 | timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT, |
| 1147 | }); | 1190 | }).pipe( |
| 1191 | // A running host learns of a Deregister here, when its dead tunnel asks | ||
| 1192 | // for a replacement. The rejection still fails the recovery. | ||
| 1193 | Effect.tapError((error) => | ||
| 1194 | error.rejection === "unauthorized" ? markLinkRevoked(credential, error) : Effect.void, | ||
| 1195 | ), | ||
| 1196 | ); | ||
| 1148 | if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") { | 1197 | if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") { |
| 1149 | return yield* new CloudLinkInternalError({ operation: "unsupported-recovered-tunnel" }); | 1198 | return yield* new CloudLinkInternalError({ operation: "unsupported-recovered-tunnel" }); |
| 1150 | } | 1199 | } |
| ... | @@ -1165,6 +1214,8 @@ const make = Effect.gen(function* () { | ... | @@ -1165,6 +1214,8 @@ const make = Effect.gen(function* () { |
| 1165 | } | 1214 | } |
| 1166 | yield* secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(encoded)); | 1215 | yield* secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(encoded)); |
| 1167 | yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); | 1216 | yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); |
| 1217 | // The relay accepted this link, so an earlier rejection no longer holds. | ||
| 1218 | yield* secrets.remove(CLOUD_LINK_REVOKED); | ||
| 1168 | return true; | 1219 | return true; |
| 1169 | }), | 1220 | }), |
| 1170 | ); | 1221 | ); |
| ... | @@ -1315,6 +1366,7 @@ const make = Effect.gen(function* () { | ... | @@ -1315,6 +1366,7 @@ const make = Effect.gen(function* () { |
| 1315 | endpointRuntimeConfig, | 1366 | endpointRuntimeConfig, |
| 1316 | publishAgentActivity, | 1367 | publishAgentActivity, |
| 1317 | holdWebhooks, | 1368 | holdWebhooks, |
| 1369 | revoked, | ||
| 1318 | ] = yield* Effect.all( | 1370 | ] = yield* Effect.all( |
| 1319 | [ | 1371 | [ |
| 1320 | secrets.get(CLOUD_LINKED_USER_ID), | 1372 | secrets.get(CLOUD_LINKED_USER_ID), |
| ... | @@ -1323,11 +1375,13 @@ const make = Effect.gen(function* () { | ... | @@ -1323,11 +1375,13 @@ const make = Effect.gen(function* () { |
| 1323 | secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), | 1375 | secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), |
| 1324 | secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), | 1376 | secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), |
| 1325 | secrets.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET), | 1377 | secrets.get(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET), |
| 1378 | secrets.get(CLOUD_LINK_REVOKED), | ||
| 1326 | ], | 1379 | ], |
| 1327 | { concurrency: 6 }, | 1380 | { concurrency: 7 }, |
| 1328 | ); | 1381 | ); |
| 1329 | return { | 1382 | return { |
| 1330 | linked: Option.isSome(cloudUserId), | 1383 | linked: Option.isSome(cloudUserId), |
| 1384 | linkRevoked: Option.isSome(cloudUserId) && Option.isSome(revoked), | ||
| 1331 | cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, | 1385 | cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, |
| 1332 | relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, | 1386 | relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, |
| 1333 | relayIssuer: Option.isSome(relayIssuer) ? bytesToString(relayIssuer.value) : null, | 1387 | relayIssuer: Option.isSome(relayIssuer) ? bytesToString(relayIssuer.value) : null, |
| ... | @@ -1365,8 +1419,9 @@ const make = Effect.gen(function* () { | ... | @@ -1365,8 +1419,9 @@ const make = Effect.gen(function* () { |
| 1365 | secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), | 1419 | secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), |
| 1366 | secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), | 1420 | secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), |
| 1367 | secrets.remove(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET), | 1421 | secrets.remove(HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET), |
| 1422 | secrets.remove(CLOUD_LINK_REVOKED), | ||
| 1368 | ], | 1423 | ], |
| 1369 | { concurrency: 9 }, | 1424 | { concurrency: 10 }, |
| 1370 | ); | 1425 | ); |
| 1371 | yield* setCliDesiredCloudLink(false).pipe(withSecrets); | 1426 | yield* setCliDesiredCloudLink(false).pipe(withSecrets); |
| 1372 | return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; | 1427 | return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; |
apps/server/src/cloud/config.ts+5| ... | @@ -17,6 +17,11 @@ export const RELAY_ISSUER_SECRET = "cloud-relay-issuer"; | ... | @@ -17,6 +17,11 @@ export const RELAY_ISSUER_SECRET = "cloud-relay-issuer"; |
| 17 | export const RELAY_ENVIRONMENT_CREDENTIAL_SECRET = "cloud-relay-environment-credential"; | 17 | export const RELAY_ENVIRONMENT_CREDENTIAL_SECRET = "cloud-relay-environment-credential"; |
| 18 | export const PUBLISH_AGENT_ACTIVITY_SECRET = "cloud-publish-agent-activity"; | 18 | export const PUBLISH_AGENT_ACTIVITY_SECRET = "cloud-publish-agent-activity"; |
| 19 | export const HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET = "cloud-hold-webhooks-while-offline"; | 19 | export const HOLD_WEBHOOKS_WHILE_OFFLINE_SECRET = "cloud-hold-webhooks-while-offline"; |
| 20 | // Set when the relay rejects this environment's credential (401), as after the | ||
| 21 | // environment is deregistered from T3 Connect. The link stays stored; the mark | ||
| 22 | // tells `t3 connect status` and clients to offer a relink. Linking, unlinking, | ||
| 23 | // or a later successful tunnel registration clears it. | ||
| 24 | export const CLOUD_LINK_REVOKED = "cloud-link-revoked"; | ||
| 20 | 25 | ||
| 21 | export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect( | 26 | export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect( |
| 22 | Schema.fromJsonString(RelayManagedEndpointRuntimeConfig), | 27 | Schema.fromJsonString(RelayManagedEndpointRuntimeConfig), |
apps/server/src/cloud/managedTunnelStartup.test.ts+1-1| ... | @@ -34,7 +34,7 @@ describe("managedTunnelStartupAction", () => { | ... | @@ -34,7 +34,7 @@ describe("managedTunnelStartupAction", () => { |
| 34 | ).toEqual({ action: "reconcile_link" }); | 34 | ).toEqual({ action: "reconcile_link" }); |
| 35 | }); | 35 | }); |
| 36 | 36 | ||
| 37 | it.each(["ready", "unavailable"] as const)( | 37 | it.each(["ready", "unavailable", "revoked"] as const)( |
| 38 | "does not provision after a %s registration result", | 38 | "does not provision after a %s registration result", |
| 39 | (status) => { | 39 | (status) => { |
| 40 | expect( | 40 | expect( |
apps/server/src/cloud/managedTunnelStartup.ts+3-1| ... | @@ -3,8 +3,10 @@ import * as Duration from "effect/Duration"; | ... | @@ -3,8 +3,10 @@ import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; | 4 | import * as Schedule from "effect/Schedule"; |
| 5 | 5 | ||
| 6 | // "revoked" leads to no action: relinking a deregistered environment is the | ||
| 7 | // user's call, so startup neither retries nor relinks it. | ||
| 6 | export type ManagedTunnelRegistrationResult = | 8 | export type ManagedTunnelRegistrationResult = |
| 7 | | { readonly status: "not_linked" | "ready" | "unavailable" | "superseded" } | 9 | | { readonly status: "not_linked" | "ready" | "unavailable" | "superseded" | "revoked" } |
| 8 | | { | 10 | | { |
| 9 | readonly status: "recovery_required"; | 11 | readonly status: "recovery_required"; |
| 10 | readonly config: RelayManagedEndpointRuntimeConfig; | 12 | readonly config: RelayManagedEndpointRuntimeConfig; |
apps/web/src/cloud/useCloudLinkController.test.tsx+19| ... | @@ -222,6 +222,25 @@ describe("useCloudLinkController", () => { | ... | @@ -222,6 +222,25 @@ describe("useCloudLinkController", () => { |
| 222 | }); | 222 | }); |
| 223 | }); | 223 | }); |
| 224 | 224 | ||
| 225 | it("shows a revoked link as off and links it again when turned on", async () => { | ||
| 226 | testState.snapshot = { ...linkedState, linkRevoked: true }; | ||
| 227 | testState.renderedSnapshot = testState.snapshot; | ||
| 228 | await mountController(); | ||
| 229 | expect(controller).toMatchObject({ | ||
| 230 | linked: false, | ||
| 231 | linkRevoked: true, | ||
| 232 | managedTunnelActive: false, | ||
| 233 | managedTunnelOutOfSync: false, | ||
| 234 | }); | ||
| 235 | |||
| 236 | expect(await reconcile({ managedTunnel: true, publish: false })).toBe(true); | ||
| 237 | expect(testState.link).toHaveBeenCalledExactlyOnceWith({ | ||
| 238 | target, | ||
| 239 | clerkToken: "clerk-token", | ||
| 240 | mode: "managed", | ||
| 241 | }); | ||
| 242 | }); | ||
| 243 | |||
| 225 | it("keeps a managed link that the relay also holds as managed", async () => { | 244 | it("keeps a managed link that the relay also holds as managed", async () => { |
| 226 | testState.relayProviderKind = "cloudflare_tunnel"; | 245 | testState.relayProviderKind = "cloudflare_tunnel"; |
| 227 | await mountController(); | 246 | await mountController(); |
apps/web/src/cloud/useCloudLinkController.ts+12-3| ... | @@ -39,7 +39,8 @@ export interface CloudLinkDesiredState { | ... | @@ -39,7 +39,8 @@ export interface CloudLinkDesiredState { |
| 39 | * outlive a relay link that drifted to publish-only, and clients then get | 39 | * outlive a relay link that drifted to publish-only, and clients then get |
| 40 | * `endpoint_provider_not_managed`. So when relay discovery has this | 40 | * `endpoint_provider_not_managed`. So when relay discovery has this |
| 41 | * environment's record, it must also say managed. Older environment servers | 41 | * environment's record, it must also say managed. Older environment servers |
| 42 | * predate managedTunnelActive; for them a link always implies a tunnel. | 42 | * predate managedTunnelActive; for them a link always implies a tunnel. A link |
| 43 | * the relay rejected (usually after a Deregister) gives no access at all. | ||
| 43 | */ | 44 | */ |
| 44 | function isManagedTunnelActive( | 45 | function isManagedTunnelActive( |
| 45 | linkState: EnvironmentCloudLinkStateResult, | 46 | linkState: EnvironmentCloudLinkStateResult, |
| ... | @@ -47,6 +48,7 @@ function isManagedTunnelActive( | ... | @@ -47,6 +48,7 @@ function isManagedTunnelActive( |
| 47 | ) { | 48 | ) { |
| 48 | const localManagedTunnel = linkState.managedTunnelActive ?? linkState.linked; | 49 | const localManagedTunnel = linkState.managedTunnelActive ?? linkState.linked; |
| 49 | return ( | 50 | return ( |
| 51 | linkState.linkRevoked !== true && | ||
| 50 | localManagedTunnel && | 52 | localManagedTunnel && |
| 51 | (relayProviderKind === undefined || relayProviderKind === "cloudflare_tunnel") | 53 | (relayProviderKind === undefined || relayProviderKind === "cloudflare_tunnel") |
| 52 | ); | 54 | ); |
| ... | @@ -105,6 +107,8 @@ export function useCloudLinkController() { | ... | @@ -105,6 +107,8 @@ export function useCloudLinkController() { |
| 105 | }; | 107 | }; |
| 106 | 108 | ||
| 107 | const linkData = primaryCloudLinkState.data; | 109 | const linkData = primaryCloudLinkState.data; |
| 110 | // A revoked link reads as off; turning it on links again. | ||
| 111 | const linkRevoked = linkData?.linkRevoked ?? false; | ||
| 108 | const managedTunnelActive = linkData ? isManagedTunnelActive(linkData, relayProviderKind) : false; | 112 | const managedTunnelActive = linkData ? isManagedTunnelActive(linkData, relayProviderKind) : false; |
| 109 | // Local state says managed but the relay holds a publish-only link. Turning | 113 | // Local state says managed but the relay holds a publish-only link. Turning |
| 110 | // T3 Connect on relinks as managed, which repairs it. | 114 | // T3 Connect on relinks as managed, which repairs it. |
| ... | @@ -112,7 +116,7 @@ export function useCloudLinkController() { | ... | @@ -112,7 +116,7 @@ export function useCloudLinkController() { |
| 112 | linkData !== null && !managedTunnelActive && isManagedTunnelActive(linkData, undefined); | 116 | linkData !== null && !managedTunnelActive && isManagedTunnelActive(linkData, undefined); |
| 113 | const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false; | 117 | const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false; |
| 114 | const holdWebhooksWhileOffline = primaryCloudLinkState.data?.holdWebhooksWhileOffline ?? false; | 118 | const holdWebhooksWhileOffline = primaryCloudLinkState.data?.holdWebhooksWhileOffline ?? false; |
| 115 | const linked = primaryCloudLinkState.data?.linked ?? false; | 119 | const linked = !linkRevoked && (primaryCloudLinkState.data?.linked ?? false); |
| 116 | 120 | ||
| 117 | const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise<boolean> => { | 121 | const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise<boolean> => { |
| 118 | setOperationError(null); | 122 | setOperationError(null); |
| ... | @@ -178,7 +182,11 @@ export function useCloudLinkController() { | ... | @@ -178,7 +182,11 @@ export function useCloudLinkController() { |
| 178 | return false; | 182 | return false; |
| 179 | } | 183 | } |
| 180 | const currentManagedTunnel = isManagedTunnelActive(currentLinkState, relayProviderKind); | 184 | const currentManagedTunnel = isManagedTunnelActive(currentLinkState, relayProviderKind); |
| 181 | if (!currentLinkState.linked || currentManagedTunnel !== desired.managedTunnel) { | 185 | if ( |
| 186 | !currentLinkState.linked || | ||
| 187 | currentLinkState.linkRevoked === true || | ||
| 188 | currentManagedTunnel !== desired.managedTunnel | ||
| 189 | ) { | ||
| 182 | const linkResult = await linkPrimaryEnvironment({ | 190 | const linkResult = await linkPrimaryEnvironment({ |
| 183 | target, | 191 | target, |
| 184 | clerkToken, | 192 | clerkToken, |
| ... | @@ -225,6 +233,7 @@ export function useCloudLinkController() { | ... | @@ -225,6 +233,7 @@ export function useCloudLinkController() { |
| 225 | isSignedIn, | 233 | isSignedIn, |
| 226 | linkState: primaryCloudLinkState, | 234 | linkState: primaryCloudLinkState, |
| 227 | linked, | 235 | linked, |
| 236 | linkRevoked, | ||
| 228 | managedTunnelActive, | 237 | managedTunnelActive, |
| 229 | managedTunnelOutOfSync, | 238 | managedTunnelOutOfSync, |
| 230 | publishAgentActivity, | 239 | publishAgentActivity, |
apps/web/src/components/cloud/ConnectOnboardingDialog.tsx+5-1| ... | @@ -173,7 +173,11 @@ function ConfiguredConnectOnboardingDialog() { | ... | @@ -173,7 +173,11 @@ function ConfiguredConnectOnboardingDialog() { |
| 173 | return; | 173 | return; |
| 174 | } | 174 | } |
| 175 | prefilledFromLinkStateRef.current = true; | 175 | prefilledFromLinkStateRef.current = true; |
| 176 | if (linkStateData.linked && linkStateData.cloudUserId === openForAccount) { | 176 | if ( |
| 177 | linkStateData.linked && | ||
| 178 | linkStateData.linkRevoked !== true && | ||
| 179 | linkStateData.cloudUserId === openForAccount | ||
| 180 | ) { | ||
| 177 | setExposeEnvironment(linkStateData.managedTunnelActive ?? linkStateData.linked); | 181 | setExposeEnvironment(linkStateData.managedTunnelActive ?? linkStateData.linked); |
| 178 | setPublishAgentActivity(linkStateData.publishAgentActivity); | 182 | setPublishAgentActivity(linkStateData.publishAgentActivity); |
| 179 | } | 183 | } |
apps/web/src/components/settings/ConnectionsSettings.tsx+13-5| ... | @@ -1828,6 +1828,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b | ... | @@ -1828,6 +1828,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1828 | const { | 1828 | const { |
| 1829 | isSignedIn, | 1829 | isSignedIn, |
| 1830 | linkState: primaryCloudLinkState, | 1830 | linkState: primaryCloudLinkState, |
| 1831 | linkRevoked, | ||
| 1831 | managedTunnelActive, | 1832 | managedTunnelActive, |
| 1832 | managedTunnelOutOfSync, | 1833 | managedTunnelOutOfSync, |
| 1833 | publishAgentActivity, | 1834 | publishAgentActivity, |
| ... | @@ -1918,11 +1919,13 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b | ... | @@ -1918,11 +1919,13 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1918 | <SettingsRow | 1919 | <SettingsRow |
| 1919 | title={searchableSetting("t3-connect").title} | 1920 | title={searchableSetting("t3-connect").title} |
| 1920 | description={ | 1921 | description={ |
| 1921 | managedTunnelActive | 1922 | linkRevoked |
| 1922 | ? "This environment is available to your other devices through T3 Connect." | 1923 | ? "This environment was removed from T3 Connect. Turn it on to link it again." |
| 1923 | : managedTunnelOutOfSync | 1924 | : managedTunnelActive |
| 1924 | ? "T3 Connect is out of sync. This environment is linked only to publish agent activity, so other devices cannot connect. Turn T3 Connect on to repair it." | 1925 | ? "This environment is available to your other devices through T3 Connect." |
| 1925 | : "Make this environment available to your other devices through T3 Connect." | 1926 | : managedTunnelOutOfSync |
| 1927 | ? "T3 Connect is out of sync. This environment is linked only to publish agent activity, so other devices cannot connect. Turn T3 Connect on to repair it." | ||
| 1928 | : "Make this environment available to your other devices through T3 Connect." | ||
| 1926 | } | 1929 | } |
| 1927 | status={operationError ?? primaryCloudLinkState.error} | 1930 | status={operationError ?? primaryCloudLinkState.error} |
| 1928 | control={ | 1931 | control={ |
| ... | @@ -1940,6 +1943,11 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b | ... | @@ -1940,6 +1943,11 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1940 | /> | 1943 | /> |
| 1941 | } | 1944 | } |
| 1942 | /> | 1945 | /> |
| 1946 | ) : linkRevoked ? ( | ||
| 1947 | <SettingsRow | ||
| 1948 | title={searchableSetting("t3-connect").title} | ||
| 1949 | description="This environment was removed from T3 Connect. Run `t3 connect link` on the host to link it again." | ||
| 1950 | /> | ||
| 1943 | ) : null} | 1951 | ) : null} |
| 1944 | <SettingsRow | 1952 | <SettingsRow |
| 1945 | title={searchableSetting("publish-agent-activity").title} | 1953 | title={searchableSetting("publish-agent-activity").title} |
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+2-1| ... | @@ -29,7 +29,8 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch | ... | @@ -29,7 +29,8 @@ export function useAvailableSettingsSearchItems(scopeSearch: SettingsScopeSearch |
| 29 | const cloudLinkState = usePrimaryCloudLinkState().data; | 29 | const cloudLinkState = usePrimaryCloudLinkState().data; |
| 30 | // Same fallback as the Connections row: older servers imply a tunnel from `linked`. | 30 | // Same fallback as the Connections row: older servers imply a tunnel from `linked`. |
| 31 | const managedTunnelActive = | 31 | const managedTunnelActive = |
| 32 | cloudLinkState?.managedTunnelActive ?? cloudLinkState?.linked ?? false; | 32 | cloudLinkState?.linkRevoked !== true && |
| 33 | (cloudLinkState?.managedTunnelActive ?? cloudLinkState?.linked ?? false); | ||
| 33 | 34 | ||
| 34 | return useMemo( | 35 | return useMemo( |
| 35 | () => | 36 | () => |
docs/user/remote-access.md+8-2| ... | @@ -290,8 +290,14 @@ the workspace. These scopes control direct file access from the client. | ... | @@ -290,8 +290,14 @@ the workspace. These scopes control direct file access from the client. |
| 290 | To remove an environment from T3 Connect, open your account menu's **T3 Connect** | 290 | To remove an environment from T3 Connect, open your account menu's **T3 Connect** |
| 291 | page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This | 291 | page, or **Settings → T3 Connect** on mobile, and choose **Deregister**. This |
| 292 | revokes its cloud access and frees its host space even when the environment is | 292 | revokes its cloud access and frees its host space even when the environment is |
| 293 | offline or has been wiped. Removing an environment from a device's connection | 293 | offline or has been wiped. When T3 Connect rejects a host's link, the host |
| 294 | settings only forgets it on that device; it stays registered to your account. | 294 | shows that it was removed from T3 Connect. To link it again, turn on |
| 295 | **T3 Connect** in **Settings → Connections** on a desktop host, or run | ||
| 296 | `t3 connect link` on a command-line host and then restart T3 Code there. A | ||
| 297 | command-line host that released its tunnel when it last stopped may link itself | ||
| 298 | again on its next start; run `t3 connect unlink` on that host to keep it off. | ||
| 299 | Removing an environment from a device's connection settings only forgets it on | ||
| 300 | that device; it stays registered to your account. | ||
| 295 | 301 | ||
| 296 | When idle tunnel cleanup is enabled, T3 Connect removes a linked environment's | 302 | When idle tunnel cleanup is enabled, T3 Connect removes a linked environment's |
| 297 | tunnel after it stays offline for several minutes. The environment stays linked | 303 | tunnel after it stays offline for several minutes. The environment stays linked |
packages/client-runtime/src/relay/errorPresentation.ts+1-1| ... | @@ -41,7 +41,7 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string { | ... | @@ -41,7 +41,7 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string { |
| 41 | // "Not authorized" covers non-auth causes too; surface the reason so a | 41 | // "Not authorized" covers non-auth causes too; surface the reason so a |
| 42 | // missing link does not read as a credential problem. | 42 | // missing link does not read as a credential problem. |
| 43 | if (error.reason === "environment_link_not_found") { | 43 | if (error.reason === "environment_link_not_found") { |
| 44 | return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; | 44 | return "Relay has no active link for this environment. If it was deregistered from T3 Connect, link it again on that machine."; |
| 45 | } | 45 | } |
| 46 | if (error.reason === "managed_endpoint_allocation_not_ready") { | 46 | if (error.reason === "managed_endpoint_allocation_not_ready") { |
| 47 | return "The environment's T3 Connect tunnel is still being set up."; | 47 | return "The environment's T3 Connect tunnel is still being set up."; |
packages/contracts/src/environmentHttp.ts+4| ... | @@ -421,6 +421,10 @@ export const EnvironmentCloudLinkStateResult = Schema.Struct({ | ... | @@ -421,6 +421,10 @@ export const EnvironmentCloudLinkStateResult = Schema.Struct({ |
| 421 | // Opt-in: T3 Connect holds webhook requests while this environment is | 421 | // Opt-in: T3 Connect holds webhook requests while this environment is |
| 422 | // offline. Optional so newer clients tolerate older environment servers. | 422 | // offline. Optional so newer clients tolerate older environment servers. |
| 423 | holdWebhooksWhileOffline: Schema.optional(Schema.Boolean), | 423 | holdWebhooksWhileOffline: Schema.optional(Schema.Boolean), |
| 424 | // The relay rejected this link's credential, usually because the environment | ||
| 425 | // was deregistered from T3 Connect. The link stays stored until it is linked | ||
| 426 | // again. Optional so newer clients tolerate older environment servers. | ||
| 427 | linkRevoked: Schema.optional(Schema.Boolean), | ||
| 424 | }); | 428 | }); |
| 425 | export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type; | 429 | export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type; |
| 426 | 430 |