| author | |
| committer | |
| log | 2a93885bac5798a79d55069a0b5dc3e53c6176bc |
| tree | 15099ecfa1c864be4a4c991ed33c37884ce5e5cc |
| parent | cdd331b6c3f78bf24b039b09937b837d2d71a4e7 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>6 files changed, 1132 insertions(+), 59 deletions(-)
apps/server/src/cli/connect.test.ts+3| ... | @@ -71,6 +71,7 @@ it.effect("does not install the relay client when the user declines the managed | ... | @@ -71,6 +71,7 @@ it.effect("does not install the relay client when the user declines the managed |
| 71 | installCalls += 1; | 71 | installCalls += 1; |
| 72 | return managedExecutable; | 72 | return managedExecutable; |
| 73 | }), | 73 | }), |
| 74 | pruneManagedVersions: Effect.void, | ||
| 74 | }, | 75 | }, |
| 75 | () => Effect.succeed(false), | 76 | () => Effect.succeed(false), |
| 76 | () => Effect.void, | 77 | () => Effect.void, |
| ... | @@ -104,6 +105,7 @@ it.effect("installs the relay client after the user accepts the managed download | ... | @@ -104,6 +105,7 @@ it.effect("installs the relay client after the user accepts the managed download |
| 104 | }), | 105 | }), |
| 105 | ), | 106 | ), |
| 106 | ), | 107 | ), |
| 108 | pruneManagedVersions: Effect.void, | ||
| 107 | }, | 109 | }, |
| 108 | () => Effect.succeed(true), | 110 | () => Effect.succeed(true), |
| 109 | (event) => | 111 | (event) => |
| ... | @@ -128,6 +130,7 @@ it.effect("reuses an available relay client executable without prompting", () => | ... | @@ -128,6 +130,7 @@ it.effect("reuses an available relay client executable without prompting", () => |
| 128 | resolve: Effect.succeed(managedExecutable), | 130 | resolve: Effect.succeed(managedExecutable), |
| 129 | install: Effect.die("unexpected install"), | 131 | install: Effect.die("unexpected install"), |
| 130 | installWithProgress: () => Effect.die("unexpected install"), | 132 | installWithProgress: () => Effect.die("unexpected install"), |
| 133 | pruneManagedVersions: Effect.void, | ||
| 131 | }, | 134 | }, |
| 132 | () => | 135 | () => |
| 133 | Effect.sync(() => { | 136 | Effect.sync(() => { |
apps/server/src/cloud/ManagedEndpointRuntime.test.ts+555| ... | @@ -29,6 +29,7 @@ const layerRelayClientAvailable = Layer.succeed( | ... | @@ -29,6 +29,7 @@ const layerRelayClientAvailable = Layer.succeed( |
| 29 | }), | 29 | }), |
| 30 | install: Effect.die("unused"), | 30 | install: Effect.die("unused"), |
| 31 | installWithProgress: () => Effect.die("unused"), | 31 | installWithProgress: () => Effect.die("unused"), |
| 32 | pruneManagedVersions: Effect.void, | ||
| 32 | }), | 33 | }), |
| 33 | ); | 34 | ); |
| 34 | 35 | ||
| ... | @@ -829,6 +830,7 @@ describe("CloudManagedEndpointRuntime", () => { | ... | @@ -829,6 +830,7 @@ describe("CloudManagedEndpointRuntime", () => { |
| 829 | }), | 830 | }), |
| 830 | install: Effect.die("unused"), | 831 | install: Effect.die("unused"), |
| 831 | installWithProgress: () => Effect.die("unused"), | 832 | installWithProgress: () => Effect.die("unused"), |
| 833 | pruneManagedVersions: Effect.void, | ||
| 832 | }), | 834 | }), |
| 833 | ), | 835 | ), |
| 834 | ); | 836 | ); |
| ... | @@ -847,4 +849,557 @@ describe("CloudManagedEndpointRuntime", () => { | ... | @@ -847,4 +849,557 @@ describe("CloudManagedEndpointRuntime", () => { |
| 847 | expect(spawn).not.toHaveBeenCalled(); | 849 | expect(spawn).not.toHaveBeenCalled(); |
| 848 | }), | 850 | }), |
| 849 | ); | 851 | ); |
| 852 | |||
| 853 | it.effect( | ||
| 854 | "runs an older relay client while the pinned one installs, then restarts on it and prunes", | ||
| 855 | () => | ||
| 856 | Effect.gen(function* () { | ||
| 857 | const olderClient = { | ||
| 858 | status: "available", | ||
| 859 | executablePath: "/managed/2025.9.0/cloudflared", | ||
| 860 | source: "managed", | ||
| 861 | version: "2025.9.0", | ||
| 862 | } as const; | ||
| 863 | const pinnedClient = { | ||
| 864 | status: "available", | ||
| 865 | executablePath: "/managed/pinned/cloudflared", | ||
| 866 | source: "managed", | ||
| 867 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 868 | } as const; | ||
| 869 | let installed = false; | ||
| 870 | const releaseInstall = yield* Deferred.make<void>(); | ||
| 871 | const pruned = yield* Deferred.make<void>(); | ||
| 872 | const spawned = yield* Queue.unbounded<string>(); | ||
| 873 | const killed: Array<string> = []; | ||
| 874 | const output = yield* Queue.unbounded<Uint8Array>(); | ||
| 875 | const spawner = ChildProcessSpawner.make((command) => | ||
| 876 | Effect.gen(function* () { | ||
| 877 | const executable = ChildProcess.isStandardCommand(command) ? command.command : ""; | ||
| 878 | yield* Queue.offer(spawned, executable); | ||
| 879 | const handle = makeHandle({ | ||
| 880 | pid: executable === pinnedClient.executablePath ? 2 : 1, | ||
| 881 | onKill: () => killed.push(executable), | ||
| 882 | ...(executable === pinnedClient.executablePath | ||
| 883 | ? { output: Stream.fromQueue(output) } | ||
| 884 | : {}), | ||
| 885 | }); | ||
| 886 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 887 | return handle; | ||
| 888 | }), | ||
| 889 | ); | ||
| 890 | const runtime = yield* buildCloudManagedEndpointRuntime( | ||
| 891 | spawner, | ||
| 892 | Layer.succeed( | ||
| 893 | RelayClient.RelayClient, | ||
| 894 | RelayClient.RelayClient.of({ | ||
| 895 | resolve: Effect.sync(() => (installed ? pinnedClient : olderClient)), | ||
| 896 | install: Deferred.await(releaseInstall).pipe( | ||
| 897 | Effect.andThen( | ||
| 898 | Effect.sync(() => { | ||
| 899 | installed = true; | ||
| 900 | return pinnedClient; | ||
| 901 | }), | ||
| 902 | ), | ||
| 903 | ), | ||
| 904 | installWithProgress: () => Effect.die("unused"), | ||
| 905 | pruneManagedVersions: Deferred.succeed(pruned, undefined).pipe(Effect.asVoid), | ||
| 906 | }), | ||
| 907 | ), | ||
| 908 | ); | ||
| 909 | |||
| 910 | const status = yield* runtime.applyConfig({ | ||
| 911 | providerKind: "cloudflare_tunnel", | ||
| 912 | connectorToken: "token", | ||
| 913 | }); | ||
| 914 | expect(status).toMatchObject({ status: "running", pid: 1 }); | ||
| 915 | expect(yield* Queue.take(spawned)).toBe(olderClient.executablePath); | ||
| 916 | |||
| 917 | yield* Deferred.succeed(releaseInstall, undefined); | ||
| 918 | expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath); | ||
| 919 | expect(killed).toEqual([olderClient.executablePath]); | ||
| 920 | |||
| 921 | yield* Queue.offer( | ||
| 922 | output, | ||
| 923 | new TextEncoder().encode( | ||
| 924 | "2026-10-08T00:00:00Z INF Registered tunnel connection connIndex=0\n", | ||
| 925 | ), | ||
| 926 | ); | ||
| 927 | yield* Deferred.await(pruned); | ||
| 928 | }), | ||
| 929 | ); | ||
| 930 | |||
| 931 | it.effect("installs a missing relay client in the background and then starts it", () => | ||
| 932 | Effect.gen(function* () { | ||
| 933 | const pinnedClient = { | ||
| 934 | status: "available", | ||
| 935 | executablePath: "/managed/pinned/cloudflared", | ||
| 936 | source: "managed", | ||
| 937 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 938 | } as const; | ||
| 939 | let installed = false; | ||
| 940 | const spawned = yield* Deferred.make<string>(); | ||
| 941 | const spawner = ChildProcessSpawner.make((command) => | ||
| 942 | Effect.gen(function* () { | ||
| 943 | yield* Deferred.succeed( | ||
| 944 | spawned, | ||
| 945 | ChildProcess.isStandardCommand(command) ? command.command : "", | ||
| 946 | ); | ||
| 947 | const handle = makeHandle({ pid: 3, onKill: () => {} }); | ||
| 948 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 949 | return handle; | ||
| 950 | }), | ||
| 951 | ); | ||
| 952 | const runtime = yield* buildCloudManagedEndpointRuntime( | ||
| 953 | spawner, | ||
| 954 | Layer.succeed( | ||
| 955 | RelayClient.RelayClient, | ||
| 956 | RelayClient.RelayClient.of({ | ||
| 957 | resolve: Effect.sync(() => | ||
| 958 | installed | ||
| 959 | ? pinnedClient | ||
| 960 | : { status: "missing" as const, version: RelayClient.CLOUDFLARED_VERSION }, | ||
| 961 | ), | ||
| 962 | install: Effect.sync(() => { | ||
| 963 | installed = true; | ||
| 964 | return pinnedClient; | ||
| 965 | }), | ||
| 966 | installWithProgress: () => Effect.die("unused"), | ||
| 967 | pruneManagedVersions: Effect.void, | ||
| 968 | }), | ||
| 969 | ), | ||
| 970 | ); | ||
| 971 | |||
| 972 | const status = yield* runtime.applyConfig({ | ||
| 973 | providerKind: "cloudflare_tunnel", | ||
| 974 | connectorToken: "token", | ||
| 975 | }); | ||
| 976 | // The first attempt reports the gap; the install then starts the connector itself. | ||
| 977 | expect(status).toMatchObject({ status: "failed", failure: "not-installed" }); | ||
| 978 | expect(yield* Deferred.await(spawned)).toBe(pinnedClient.executablePath); | ||
| 979 | }), | ||
| 980 | ); | ||
| 981 | |||
| 982 | it.effect("leaves a PATH relay client alone", () => | ||
| 983 | Effect.gen(function* () { | ||
| 984 | const install = vi.fn(() => Effect.die("unexpected install")); | ||
| 985 | const spawner = ChildProcessSpawner.make(() => | ||
| 986 | Effect.gen(function* () { | ||
| 987 | const handle = makeHandle({ pid: 8, onKill: () => {} }); | ||
| 988 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 989 | return handle; | ||
| 990 | }), | ||
| 991 | ); | ||
| 992 | const runtime = yield* buildCloudManagedEndpointRuntime( | ||
| 993 | spawner, | ||
| 994 | Layer.succeed( | ||
| 995 | RelayClient.RelayClient, | ||
| 996 | RelayClient.RelayClient.of({ | ||
| 997 | resolve: Effect.succeed({ | ||
| 998 | status: "available", | ||
| 999 | executablePath: "/opt/homebrew/bin/cloudflared", | ||
| 1000 | source: "path", | ||
| 1001 | version: "2026.10.0", | ||
| 1002 | }), | ||
| 1003 | install: Effect.suspend(install), | ||
| 1004 | installWithProgress: () => Effect.die("unused"), | ||
| 1005 | pruneManagedVersions: Effect.void, | ||
| 1006 | }), | ||
| 1007 | ), | ||
| 1008 | ); | ||
| 1009 | expect( | ||
| 1010 | yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }), | ||
| 1011 | ).toMatchObject({ status: "running", pid: 8 }); | ||
| 1012 | yield* TestClock.adjust(Duration.minutes(30)); | ||
| 1013 | expect(install).not.toHaveBeenCalled(); | ||
| 1014 | }), | ||
| 1015 | ); | ||
| 1016 | |||
| 1017 | it.effect("leaves an override relay client alone", () => | ||
| 1018 | Effect.gen(function* () { | ||
| 1019 | const install = vi.fn(() => Effect.die("unexpected install")); | ||
| 1020 | const spawner = ChildProcessSpawner.make(() => | ||
| 1021 | Effect.gen(function* () { | ||
| 1022 | const handle = makeHandle({ pid: 4, onKill: () => {} }); | ||
| 1023 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 1024 | return handle; | ||
| 1025 | }), | ||
| 1026 | ); | ||
| 1027 | const runtime = yield* buildCloudManagedEndpointRuntime( | ||
| 1028 | spawner, | ||
| 1029 | Layer.succeed( | ||
| 1030 | RelayClient.RelayClient, | ||
| 1031 | RelayClient.RelayClient.of({ | ||
| 1032 | resolve: Effect.succeed({ | ||
| 1033 | status: "available", | ||
| 1034 | executablePath: "/opt/cloudflared", | ||
| 1035 | source: "override", | ||
| 1036 | version: "2025.10.0", | ||
| 1037 | }), | ||
| 1038 | install: Effect.suspend(install), | ||
| 1039 | installWithProgress: () => Effect.die("unused"), | ||
| 1040 | pruneManagedVersions: Effect.void, | ||
| 1041 | }), | ||
| 1042 | ), | ||
| 1043 | ); | ||
| 1044 | expect( | ||
| 1045 | yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }), | ||
| 1046 | ).toMatchObject({ status: "running", pid: 4 }); | ||
| 1047 | yield* Effect.yieldNow; | ||
| 1048 | expect(install).not.toHaveBeenCalled(); | ||
| 1049 | }), | ||
| 1050 | ); | ||
| 1051 | |||
| 1052 | it.effect("retries a failed background install while the older connector keeps running", () => | ||
| 1053 | Effect.gen(function* () { | ||
| 1054 | const olderClient = { | ||
| 1055 | status: "available", | ||
| 1056 | executablePath: "/managed/2025.9.0/cloudflared", | ||
| 1057 | source: "managed", | ||
| 1058 | version: "2025.9.0", | ||
| 1059 | } as const; | ||
| 1060 | const pinnedClient = { | ||
| 1061 | status: "available", | ||
| 1062 | executablePath: "/managed/pinned/cloudflared", | ||
| 1063 | source: "managed", | ||
| 1064 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 1065 | } as const; | ||
| 1066 | let installed = false; | ||
| 1067 | let installAttempts = 0; | ||
| 1068 | const spawned = yield* Queue.unbounded<string>(); | ||
| 1069 | const spawner = ChildProcessSpawner.make((command) => | ||
| 1070 | Effect.gen(function* () { | ||
| 1071 | yield* Queue.offer( | ||
| 1072 | spawned, | ||
| 1073 | ChildProcess.isStandardCommand(command) ? command.command : "", | ||
| 1074 | ); | ||
| 1075 | const handle = makeHandle({ pid: 5, onKill: () => {} }); | ||
| 1076 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 1077 | return handle; | ||
| 1078 | }), | ||
| 1079 | ); | ||
| 1080 | const runtime = yield* buildCloudManagedEndpointRuntime( | ||
| 1081 | spawner, | ||
| 1082 | Layer.succeed( | ||
| 1083 | RelayClient.RelayClient, | ||
| 1084 | RelayClient.RelayClient.of({ | ||
| 1085 | resolve: Effect.sync(() => (installed ? pinnedClient : olderClient)), | ||
| 1086 | install: Effect.suspend(() => { | ||
| 1087 | installAttempts += 1; | ||
| 1088 | if (installAttempts === 1) { | ||
| 1089 | // A real download fails some time after it starts. | ||
| 1090 | return Effect.sleep(Duration.seconds(30)).pipe( | ||
| 1091 | Effect.andThen( | ||
| 1092 | Effect.fail( | ||
| 1093 | new RelayClient.RelayClientInstallError({ | ||
| 1094 | reason: "download_failed", | ||
| 1095 | message: "offline", | ||
| 1096 | }), | ||
| 1097 | ), | ||
| 1098 | ), | ||
| 1099 | ); | ||
| 1100 | } | ||
| 1101 | installed = true; | ||
| 1102 | return Effect.succeed(pinnedClient); | ||
| 1103 | }), | ||
| 1104 | installWithProgress: () => Effect.die("unused"), | ||
| 1105 | pruneManagedVersions: Effect.void, | ||
| 1106 | }), | ||
| 1107 | ), | ||
| 1108 | ); | ||
| 1109 | |||
| 1110 | yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }); | ||
| 1111 | expect(yield* Queue.take(spawned)).toBe(olderClient.executablePath); | ||
| 1112 | yield* TestClock.adjust(Duration.seconds(30)); | ||
| 1113 | expect(installAttempts).toBe(1); | ||
| 1114 | |||
| 1115 | yield* TestClock.adjust(Duration.minutes(10)); | ||
| 1116 | expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath); | ||
| 1117 | expect(installAttempts).toBe(2); | ||
| 1118 | }), | ||
| 1119 | ); | ||
| 1120 | |||
| 1121 | it.effect("requests recovery when the updated relay client fails to start", () => | ||
| 1122 | Effect.gen(function* () { | ||
| 1123 | const olderClient = { | ||
| 1124 | status: "available", | ||
| 1125 | executablePath: "/managed/2025.9.0/cloudflared", | ||
| 1126 | source: "managed", | ||
| 1127 | version: "2025.9.0", | ||
| 1128 | } as const; | ||
| 1129 | const pinnedClient = { | ||
| 1130 | status: "available", | ||
| 1131 | executablePath: "/managed/pinned/cloudflared", | ||
| 1132 | source: "managed", | ||
| 1133 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 1134 | } as const; | ||
| 1135 | let installed = false; | ||
| 1136 | const releaseInstall = yield* Deferred.make<void>(); | ||
| 1137 | const spawner = ChildProcessSpawner.make((command) => | ||
| 1138 | Effect.gen(function* () { | ||
| 1139 | const executable = ChildProcess.isStandardCommand(command) ? command.command : ""; | ||
| 1140 | if (executable === pinnedClient.executablePath) { | ||
| 1141 | return yield* PlatformError.systemError({ | ||
| 1142 | _tag: "PermissionDenied", | ||
| 1143 | module: "ChildProcess", | ||
| 1144 | method: "spawn", | ||
| 1145 | }); | ||
| 1146 | } | ||
| 1147 | const handle = makeHandle({ pid: 6, onKill: () => {} }); | ||
| 1148 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 1149 | return handle; | ||
| 1150 | }), | ||
| 1151 | ); | ||
| 1152 | const runtime = yield* buildCloudManagedEndpointRuntime( | ||
| 1153 | spawner, | ||
| 1154 | Layer.succeed( | ||
| 1155 | RelayClient.RelayClient, | ||
| 1156 | RelayClient.RelayClient.of({ | ||
| 1157 | resolve: Effect.sync(() => (installed ? pinnedClient : olderClient)), | ||
| 1158 | install: Deferred.await(releaseInstall).pipe( | ||
| 1159 | Effect.andThen( | ||
| 1160 | Effect.sync(() => { | ||
| 1161 | installed = true; | ||
| 1162 | return pinnedClient; | ||
| 1163 | }), | ||
| 1164 | ), | ||
| 1165 | ), | ||
| 1166 | installWithProgress: () => Effect.die("unused"), | ||
| 1167 | pruneManagedVersions: Effect.void, | ||
| 1168 | }), | ||
| 1169 | ), | ||
| 1170 | ); | ||
| 1171 | const config = { providerKind: "cloudflare_tunnel", connectorToken: "token" } as const; | ||
| 1172 | expect(yield* runtime.applyConfig(config)).toMatchObject({ status: "running", pid: 6 }); | ||
| 1173 | |||
| 1174 | yield* Deferred.succeed(releaseInstall, undefined); | ||
| 1175 | expect(Option.getOrThrow(yield* Stream.runHead(runtime.recoveryRequests))).toEqual(config); | ||
| 1176 | }), | ||
| 1177 | ); | ||
| 1178 | |||
| 1179 | it.effect("restarts a self-updated pinned-folder connector once the pin is reinstalled", () => | ||
| 1180 | Effect.gen(function* () { | ||
| 1181 | const executablePath = "/managed/pinned/cloudflared"; | ||
| 1182 | let version = "2026.9.3"; | ||
| 1183 | const releaseInstall = yield* Deferred.make<void>(); | ||
| 1184 | const spawned = yield* Queue.unbounded<string>(); | ||
| 1185 | const spawner = ChildProcessSpawner.make(() => | ||
| 1186 | Effect.gen(function* () { | ||
| 1187 | yield* Queue.offer(spawned, version); | ||
| 1188 | const handle = makeHandle({ pid: 7, onKill: () => {} }); | ||
| 1189 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 1190 | return handle; | ||
| 1191 | }), | ||
| 1192 | ); | ||
| 1193 | const current = () => | ||
| 1194 | ({ status: "available", executablePath, source: "managed", version }) as const; | ||
| 1195 | const runtime = yield* buildCloudManagedEndpointRuntime( | ||
| 1196 | spawner, | ||
| 1197 | Layer.succeed( | ||
| 1198 | RelayClient.RelayClient, | ||
| 1199 | RelayClient.RelayClient.of({ | ||
| 1200 | resolve: Effect.sync(current), | ||
| 1201 | install: Deferred.await(releaseInstall).pipe( | ||
| 1202 | Effect.andThen( | ||
| 1203 | Effect.sync(() => { | ||
| 1204 | version = RelayClient.CLOUDFLARED_VERSION; | ||
| 1205 | return current(); | ||
| 1206 | }), | ||
| 1207 | ), | ||
| 1208 | ), | ||
| 1209 | installWithProgress: () => Effect.die("unused"), | ||
| 1210 | pruneManagedVersions: Effect.void, | ||
| 1211 | }), | ||
| 1212 | ), | ||
| 1213 | ); | ||
| 1214 | |||
| 1215 | yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }); | ||
| 1216 | expect(yield* Queue.take(spawned)).toBe("2026.9.3"); | ||
| 1217 | yield* Deferred.succeed(releaseInstall, undefined); | ||
| 1218 | expect(yield* Queue.take(spawned)).toBe(RelayClient.CLOUDFLARED_VERSION); | ||
| 1219 | }), | ||
| 1220 | ); | ||
| 1221 | |||
| 1222 | it.effect("retries a failed install when no relay client is installed", () => | ||
| 1223 | Effect.gen(function* () { | ||
| 1224 | const pinnedClient = { | ||
| 1225 | status: "available", | ||
| 1226 | executablePath: "/managed/pinned/cloudflared", | ||
| 1227 | source: "managed", | ||
| 1228 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 1229 | } as const; | ||
| 1230 | let installed = false; | ||
| 1231 | let installAttempts = 0; | ||
| 1232 | const spawned = yield* Deferred.make<string>(); | ||
| 1233 | const spawner = ChildProcessSpawner.make((command) => | ||
| 1234 | Effect.gen(function* () { | ||
| 1235 | yield* Deferred.succeed( | ||
| 1236 | spawned, | ||
| 1237 | ChildProcess.isStandardCommand(command) ? command.command : "", | ||
| 1238 | ); | ||
| 1239 | const handle = makeHandle({ pid: 9, onKill: () => {} }); | ||
| 1240 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 1241 | return handle; | ||
| 1242 | }), | ||
| 1243 | ); | ||
| 1244 | const runtime = yield* buildCloudManagedEndpointRuntime( | ||
| 1245 | spawner, | ||
| 1246 | Layer.succeed( | ||
| 1247 | RelayClient.RelayClient, | ||
| 1248 | RelayClient.RelayClient.of({ | ||
| 1249 | resolve: Effect.sync(() => | ||
| 1250 | installed | ||
| 1251 | ? pinnedClient | ||
| 1252 | : { status: "missing" as const, version: RelayClient.CLOUDFLARED_VERSION }, | ||
| 1253 | ), | ||
| 1254 | install: Effect.suspend(() => { | ||
| 1255 | installAttempts += 1; | ||
| 1256 | if (installAttempts === 1) { | ||
| 1257 | return Effect.fail( | ||
| 1258 | new RelayClient.RelayClientInstallError({ | ||
| 1259 | reason: "download_failed", | ||
| 1260 | message: "offline", | ||
| 1261 | }), | ||
| 1262 | ); | ||
| 1263 | } | ||
| 1264 | installed = true; | ||
| 1265 | return Effect.succeed(pinnedClient); | ||
| 1266 | }), | ||
| 1267 | installWithProgress: () => Effect.die("unused"), | ||
| 1268 | pruneManagedVersions: Effect.void, | ||
| 1269 | }), | ||
| 1270 | ), | ||
| 1271 | ); | ||
| 1272 | |||
| 1273 | expect( | ||
| 1274 | yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }), | ||
| 1275 | ).toMatchObject({ status: "failed", failure: "not-installed" }); | ||
| 1276 | yield* Effect.yieldNow; | ||
| 1277 | expect(installAttempts).toBe(1); | ||
| 1278 | |||
| 1279 | yield* TestClock.adjust(Duration.minutes(10)); | ||
| 1280 | expect(yield* Deferred.await(spawned)).toBe(pinnedClient.executablePath); | ||
| 1281 | expect(installAttempts).toBe(2); | ||
| 1282 | }), | ||
| 1283 | ); | ||
| 1284 | |||
| 1285 | it.effect("restarts on the pin when the first post-install probe failed", () => | ||
| 1286 | Effect.gen(function* () { | ||
| 1287 | const olderClient = { | ||
| 1288 | status: "available", | ||
| 1289 | executablePath: "/managed/2025.9.0/cloudflared", | ||
| 1290 | source: "managed", | ||
| 1291 | version: "2025.9.0", | ||
| 1292 | } as const; | ||
| 1293 | const pinnedClient = { | ||
| 1294 | status: "available", | ||
| 1295 | executablePath: "/managed/pinned/cloudflared", | ||
| 1296 | source: "managed", | ||
| 1297 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 1298 | } as const; | ||
| 1299 | let installed = false; | ||
| 1300 | // The first resolve after the install misses the new binary, as when a | ||
| 1301 | // scanner briefly holds it; later resolves see it. | ||
| 1302 | let missedProbes = 0; | ||
| 1303 | const spawned = yield* Queue.unbounded<string>(); | ||
| 1304 | const spawner = ChildProcessSpawner.make((command) => | ||
| 1305 | Effect.gen(function* () { | ||
| 1306 | yield* Queue.offer( | ||
| 1307 | spawned, | ||
| 1308 | ChildProcess.isStandardCommand(command) ? command.command : "", | ||
| 1309 | ); | ||
| 1310 | const handle = makeHandle({ pid: 10, onKill: () => {} }); | ||
| 1311 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 1312 | return handle; | ||
| 1313 | }), | ||
| 1314 | ); | ||
| 1315 | const runtime = yield* buildCloudManagedEndpointRuntime( | ||
| 1316 | spawner, | ||
| 1317 | Layer.succeed( | ||
| 1318 | RelayClient.RelayClient, | ||
| 1319 | RelayClient.RelayClient.of({ | ||
| 1320 | resolve: Effect.sync(() => { | ||
| 1321 | if (installed && missedProbes === 0) { | ||
| 1322 | missedProbes += 1; | ||
| 1323 | return olderClient; | ||
| 1324 | } | ||
| 1325 | return installed ? pinnedClient : olderClient; | ||
| 1326 | }), | ||
| 1327 | install: Effect.sync(() => { | ||
| 1328 | installed = true; | ||
| 1329 | return pinnedClient; | ||
| 1330 | }), | ||
| 1331 | installWithProgress: () => Effect.die("unused"), | ||
| 1332 | pruneManagedVersions: Effect.void, | ||
| 1333 | }), | ||
| 1334 | ), | ||
| 1335 | ); | ||
| 1336 | |||
| 1337 | yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }); | ||
| 1338 | expect(yield* Queue.take(spawned)).toBe(olderClient.executablePath); | ||
| 1339 | yield* Effect.yieldNow; | ||
| 1340 | // The missed probe kept the older connector running. | ||
| 1341 | expect(Option.isNone(yield* Queue.poll(spawned))).toBe(true); | ||
| 1342 | |||
| 1343 | yield* TestClock.adjust(Duration.minutes(10)); | ||
| 1344 | expect(yield* Queue.take(spawned)).toBe(pinnedClient.executablePath); | ||
| 1345 | }), | ||
| 1346 | ); | ||
| 1347 | |||
| 1348 | it.effect("starts the connector when the pin landed but its first probe failed", () => | ||
| 1349 | Effect.gen(function* () { | ||
| 1350 | const pinnedClient = { | ||
| 1351 | status: "available", | ||
| 1352 | executablePath: "/managed/pinned/cloudflared", | ||
| 1353 | source: "managed", | ||
| 1354 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 1355 | } as const; | ||
| 1356 | const missing = { status: "missing" as const, version: RelayClient.CLOUDFLARED_VERSION }; | ||
| 1357 | let installed = false; | ||
| 1358 | // The resolve right after the install misses the new binary. | ||
| 1359 | let missedProbes = 0; | ||
| 1360 | const spawned = yield* Deferred.make<string>(); | ||
| 1361 | const spawner = ChildProcessSpawner.make((command) => | ||
| 1362 | Effect.gen(function* () { | ||
| 1363 | yield* Deferred.succeed( | ||
| 1364 | spawned, | ||
| 1365 | ChildProcess.isStandardCommand(command) ? command.command : "", | ||
| 1366 | ); | ||
| 1367 | const handle = makeHandle({ pid: 11, onKill: () => {} }); | ||
| 1368 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 1369 | return handle; | ||
| 1370 | }), | ||
| 1371 | ); | ||
| 1372 | const runtime = yield* buildCloudManagedEndpointRuntime( | ||
| 1373 | spawner, | ||
| 1374 | Layer.succeed( | ||
| 1375 | RelayClient.RelayClient, | ||
| 1376 | RelayClient.RelayClient.of({ | ||
| 1377 | resolve: Effect.sync(() => { | ||
| 1378 | if (!installed) return missing; | ||
| 1379 | if (missedProbes === 0) { | ||
| 1380 | missedProbes += 1; | ||
| 1381 | return missing; | ||
| 1382 | } | ||
| 1383 | return pinnedClient; | ||
| 1384 | }), | ||
| 1385 | install: Effect.sync(() => { | ||
| 1386 | installed = true; | ||
| 1387 | return pinnedClient; | ||
| 1388 | }), | ||
| 1389 | installWithProgress: () => Effect.die("unused"), | ||
| 1390 | pruneManagedVersions: Effect.void, | ||
| 1391 | }), | ||
| 1392 | ), | ||
| 1393 | ); | ||
| 1394 | |||
| 1395 | expect( | ||
| 1396 | yield* runtime.applyConfig({ providerKind: "cloudflare_tunnel", connectorToken: "token" }), | ||
| 1397 | ).toMatchObject({ status: "failed", failure: "not-installed" }); | ||
| 1398 | yield* Effect.yieldNow; | ||
| 1399 | expect(Option.isNone(yield* Deferred.poll(spawned))).toBe(true); | ||
| 1400 | |||
| 1401 | yield* TestClock.adjust(Duration.minutes(10)); | ||
| 1402 | expect(yield* Deferred.await(spawned)).toBe(pinnedClient.executablePath); | ||
| 1403 | }), | ||
| 1404 | ); | ||
| 850 | }); | 1405 | }); |
apps/server/src/cloud/ManagedEndpointRuntime.ts+140| ... | @@ -55,6 +55,7 @@ export class CloudManagedEndpointRuntime extends Context.Service< | ... | @@ -55,6 +55,7 @@ export class CloudManagedEndpointRuntime extends Context.Service< |
| 55 | 55 | ||
| 56 | interface ActiveConnector { | 56 | interface ActiveConnector { |
| 57 | readonly child: ChildProcessSpawner.ChildProcessHandle; | 57 | readonly child: ChildProcessSpawner.ChildProcessHandle; |
| 58 | readonly executable: RelayClient.AvailableRelayClient; | ||
| 58 | readonly scope: Scope.Closeable; | 59 | readonly scope: Scope.Closeable; |
| 59 | readonly configKey: string; | 60 | readonly configKey: string; |
| 60 | readonly config: RelayManagedEndpointRuntimeConfig; | 61 | readonly config: RelayManagedEndpointRuntimeConfig; |
| ... | @@ -77,6 +78,24 @@ const TUNNEL_AUTHORIZATION_FAILURES_BEFORE_RECOVERY = 4; | ... | @@ -77,6 +78,24 @@ const TUNNEL_AUTHORIZATION_FAILURES_BEFORE_RECOVERY = 4; |
| 77 | // Ask for recovery after this long, and again at this interval while it stays | 78 | // Ask for recovery after this long, and again at this interval while it stays |
| 78 | // unconnected; the relay hands back the same tunnel when it is still live. | 79 | // unconnected; the relay hands back the same tunnel when it is still live. |
| 79 | const CONNECTOR_REGISTRATION_TIMEOUT = Duration.minutes(3); | 80 | const CONNECTOR_REGISTRATION_TIMEOUT = Duration.minutes(3); |
| 81 | // A failed background install of the pinned relay client is retried no sooner | ||
| 82 | // than this, so a crash-looping connector or an offline host does not redownload | ||
| 83 | // on every reconcile. | ||
| 84 | const RELAY_CLIENT_INSTALL_RETRY_INTERVAL_MS = 10 * 60_000; | ||
| 85 | |||
| 86 | /** | ||
| 87 | * A linked host converges on the pinned managed release when it has no relay | ||
| 88 | * client or runs another managed release. A PATH binary or an explicit override | ||
| 89 | * is the user's choice, and the CLI asks before downloading, so both stay put. | ||
| 90 | */ | ||
| 91 | function needsPinnedRelayClient(executable: RelayClient.RelayClientStatus): boolean { | ||
| 92 | return ( | ||
| 93 | executable.status === "missing" || | ||
| 94 | (executable.status === "available" && | ||
| 95 | executable.source === "managed" && | ||
| 96 | !RelayClient.isPinnedManagedRelayClient(executable)) | ||
| 97 | ); | ||
| 98 | } | ||
| 80 | 99 | ||
| 81 | export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" { | 100 | export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" { |
| 82 | if (/\bRegistered tunnel connection\b/iu.test(line)) { | 101 | if (/\bRegistered tunnel connection\b/iu.test(line)) { |
| ... | @@ -148,6 +167,11 @@ export const make = Effect.gen(function* () { | ... | @@ -148,6 +167,11 @@ export const make = Effect.gen(function* () { |
| 148 | const reconcileSemaphore = yield* Semaphore.make(1); | 167 | const reconcileSemaphore = yield* Semaphore.make(1); |
| 149 | const restartDelayRef = yield* Ref.make(0); | 168 | const restartDelayRef = yield* Ref.make(0); |
| 150 | const linkStateSemaphore = yield* Semaphore.make(1); | 169 | const linkStateSemaphore = yield* Semaphore.make(1); |
| 170 | const runtimeScope = yield* Effect.scope; | ||
| 171 | const installInFlightRef = yield* Ref.make(false); | ||
| 172 | const lastInstallFailureAtRef = yield* Ref.make<number | null>(null); | ||
| 173 | const prunedRef = yield* Ref.make(false); | ||
| 174 | const retryLoopRunningRef = yield* Ref.make(false); | ||
| 151 | let reconcileConfig: CloudManagedEndpointRuntime["Service"]["applyConfig"]; | 175 | let reconcileConfig: CloudManagedEndpointRuntime["Service"]["applyConfig"]; |
| 152 | 176 | ||
| 153 | const stopActive = Effect.gen(function* () { | 177 | const stopActive = Effect.gen(function* () { |
| ... | @@ -228,6 +252,106 @@ export const make = Effect.gen(function* () { | ... | @@ -228,6 +252,106 @@ export const make = Effect.gen(function* () { |
| 228 | Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), | 252 | Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), |
| 229 | ); | 253 | ); |
| 230 | 254 | ||
| 255 | // Installs the pinned relay client in the background, then restarts the | ||
| 256 | // connector on it. The running connector, if any, keeps serving until the new | ||
| 257 | // binary is installed and validated, so an update costs one brief reconnect. | ||
| 258 | const installPinnedRelayClient = Effect.gen(function* () { | ||
| 259 | if (yield* Ref.getAndSet(installInFlightRef, true)) return; | ||
| 260 | yield* Effect.logInfo("Installing the pinned relay client", { | ||
| 261 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 262 | }); | ||
| 263 | yield* relayClient.install.pipe( | ||
| 264 | Effect.tap(() => Ref.set(lastInstallFailureAtRef, null)), | ||
| 265 | Effect.flatMap((installed) => | ||
| 266 | reconcileSemaphore.withPermits(1)( | ||
| 267 | Effect.gen(function* () { | ||
| 268 | const desiredConfig = yield* Ref.get(desiredConfigRef); | ||
| 269 | if (!desiredConfig || desiredConfig.providerKind !== "cloudflare_tunnel") return; | ||
| 270 | const active = yield* Ref.get(activeRef); | ||
| 271 | // A self-updated binary in the pinned folder shares the installed path, | ||
| 272 | // so the version decides whether the running connector is current. | ||
| 273 | if ( | ||
| 274 | active?.executable.executablePath === installed.executablePath && | ||
| 275 | active.executable.version === installed.version | ||
| 276 | ) { | ||
| 277 | return; | ||
| 278 | } | ||
| 279 | // A fresh binary can fail its probe while a scanner holds it; restarting | ||
| 280 | // then would land on the same older binary, so leave it to the retry. | ||
| 281 | const resolved = yield* relayClient.resolve; | ||
| 282 | if ( | ||
| 283 | resolved.status !== "available" || | ||
| 284 | resolved.executablePath !== installed.executablePath || | ||
| 285 | resolved.version !== installed.version | ||
| 286 | ) { | ||
| 287 | return; | ||
| 288 | } | ||
| 289 | yield* Effect.logInfo("Relay client installed; restarting the connector on it", { | ||
| 290 | version: installed.version, | ||
| 291 | previousVersion: active?.executable.version, | ||
| 292 | }); | ||
| 293 | yield* stopActive; | ||
| 294 | const status = yield* reconcileConfig(desiredConfig); | ||
| 295 | // The old connector is gone and no supervisor watches a failed spawn, | ||
| 296 | // so ask for recovery like an exited connector would. | ||
| 297 | if (status.status === "failed") { | ||
| 298 | yield* Effect.logWarning("Relay client did not start after the update", status); | ||
| 299 | yield* Queue.offer(recoveryRequests, desiredConfig); | ||
| 300 | } | ||
| 301 | }), | ||
| 302 | ), | ||
| 303 | ), | ||
| 304 | Effect.catchTags({ | ||
| 305 | RelayClientInstallError: (error) => | ||
| 306 | Clock.currentTimeMillis.pipe( | ||
| 307 | Effect.flatMap((failedAt) => Ref.set(lastInstallFailureAtRef, failedAt)), | ||
| 308 | Effect.andThen( | ||
| 309 | Effect.logWarning("Could not install the pinned relay client", { | ||
| 310 | reason: error.reason, | ||
| 311 | message: error.message, | ||
| 312 | cause: error.cause, | ||
| 313 | }), | ||
| 314 | ), | ||
| 315 | ), | ||
| 316 | }), | ||
| 317 | Effect.ensuring(Ref.set(installInFlightRef, false)), | ||
| 318 | ); | ||
| 319 | }); | ||
| 320 | |||
| 321 | // Reconciles call this; a recent failure skips the attempt so a crash-looping | ||
| 322 | // connector or an offline host does not redownload on every restart. | ||
| 323 | const ensurePinnedRelayClient = Effect.gen(function* () { | ||
| 324 | const now = yield* Clock.currentTimeMillis; | ||
| 325 | const lastFailureAt = yield* Ref.get(lastInstallFailureAtRef); | ||
| 326 | if (lastFailureAt !== null && now - lastFailureAt < RELAY_CLIENT_INSTALL_RETRY_INTERVAL_MS) { | ||
| 327 | return; | ||
| 328 | } | ||
| 329 | yield* installPinnedRelayClient; | ||
| 330 | }).pipe(Effect.forkIn(runtimeScope), Effect.asVoid); | ||
| 331 | |||
| 332 | // Until the host runs the pinned release, retry the install every interval. | ||
| 333 | // applyConfig returns early while an older connector is healthy, and some | ||
| 334 | // activations never retry a missing client, so one runtime loop covers both. | ||
| 335 | // It paces itself, so it skips the failure gate above. | ||
| 336 | const retryPinnedRelayClientInstall = Effect.gen(function* () { | ||
| 337 | while (true) { | ||
| 338 | yield* Effect.sleep(Duration.millis(RELAY_CLIENT_INSTALL_RETRY_INTERVAL_MS)); | ||
| 339 | const desiredConfig = yield* Ref.get(desiredConfigRef); | ||
| 340 | if (!desiredConfig || desiredConfig.providerKind !== "cloudflare_tunnel") return; | ||
| 341 | // Stop only once a connector runs on the pin. The pin can be on disk while | ||
| 342 | // an older connector still runs, or while none runs because a post-install | ||
| 343 | // probe failed; another install call then finds it and starts the connector. | ||
| 344 | const active = yield* Ref.get(activeRef); | ||
| 345 | if (active && !needsPinnedRelayClient(active.executable)) return; | ||
| 346 | yield* installPinnedRelayClient; | ||
| 347 | } | ||
| 348 | }).pipe(Effect.ensuring(Ref.set(retryLoopRunningRef, false))); | ||
| 349 | |||
| 350 | const startPinnedRelayClientRetries = Effect.gen(function* () { | ||
| 351 | if (yield* Ref.getAndSet(retryLoopRunningRef, true)) return; | ||
| 352 | yield* Effect.forkIn(retryPinnedRelayClientInstall, runtimeScope); | ||
| 353 | }); | ||
| 354 | |||
| 231 | // Requests recovery while the connector has not registered a connection, | 355 | // Requests recovery while the connector has not registered a connection, |
| 232 | // once per timeout, until it connects or is replaced. | 356 | // once per timeout, until it connects or is replaced. |
| 233 | const watchConnectorRegistration = (connector: ActiveConnector) => | 357 | const watchConnectorRegistration = (connector: ActiveConnector) => |
| ... | @@ -270,6 +394,15 @@ export const make = Effect.gen(function* () { | ... | @@ -270,6 +394,15 @@ export const make = Effect.gen(function* () { |
| 270 | Effect.logInfo("Relay client tunnel connection registered", attributes), | 394 | Effect.logInfo("Relay client tunnel connection registered", attributes), |
| 271 | ), | 395 | ), |
| 272 | Effect.andThen(Queue.offer(tunnelConnections, undefined)), | 396 | Effect.andThen(Queue.offer(tunnelConnections, undefined)), |
| 397 | Effect.andThen( | ||
| 398 | RelayClient.isPinnedManagedRelayClient(connector.executable) | ||
| 399 | ? Ref.getAndSet(prunedRef, true).pipe( | ||
| 400 | Effect.flatMap((pruned) => | ||
| 401 | pruned ? Effect.void : relayClient.pruneManagedVersions, | ||
| 402 | ), | ||
| 403 | ) | ||
| 404 | : Effect.void, | ||
| 405 | ), | ||
| 273 | Effect.asVoid, | 406 | Effect.asVoid, |
| 274 | ); | 407 | ); |
| 275 | case "warning": | 408 | case "warning": |
| ... | @@ -328,6 +461,10 @@ export const make = Effect.gen(function* () { | ... | @@ -328,6 +461,10 @@ export const make = Effect.gen(function* () { |
| 328 | yield* stopActive; | 461 | yield* stopActive; |
| 329 | 462 | ||
| 330 | const executable = yield* relayClient.resolve; | 463 | const executable = yield* relayClient.resolve; |
| 464 | if (needsPinnedRelayClient(executable)) { | ||
| 465 | yield* ensurePinnedRelayClient; | ||
| 466 | yield* startPinnedRelayClientRetries; | ||
| 467 | } | ||
| 331 | if (executable.status !== "available") { | 468 | if (executable.status !== "available") { |
| 332 | return { | 469 | return { |
| 333 | status: "failed", | 470 | status: "failed", |
| ... | @@ -365,6 +502,8 @@ export const make = Effect.gen(function* () { | ... | @@ -365,6 +502,8 @@ export const make = Effect.gen(function* () { |
| 365 | Effect.tap((child) => | 502 | Effect.tap((child) => |
| 366 | Effect.logInfo("Relay client process started; waiting for tunnel connection", { | 503 | Effect.logInfo("Relay client process started; waiting for tunnel connection", { |
| 367 | pid: Number(child.pid), | 504 | pid: Number(child.pid), |
| 505 | source: executable.source, | ||
| 506 | version: executable.version, | ||
| 368 | tunnelId: config.tunnelId, | 507 | tunnelId: config.tunnelId, |
| 369 | tunnelName: config.tunnelName, | 508 | tunnelName: config.tunnelName, |
| 370 | }), | 509 | }), |
| ... | @@ -395,6 +534,7 @@ export const make = Effect.gen(function* () { | ... | @@ -395,6 +534,7 @@ export const make = Effect.gen(function* () { |
| 395 | if (!("status" in child)) { | 534 | if (!("status" in child)) { |
| 396 | const connector = { | 535 | const connector = { |
| 397 | child, | 536 | child, |
| 537 | executable, | ||
| 398 | scope: connectorScope, | 538 | scope: connectorScope, |
| 399 | configKey: nextConfigKey, | 539 | configKey: nextConfigKey, |
| 400 | config, | 540 | config, |
docs/internals/t3-connect.md+24| ... | @@ -130,6 +130,30 @@ and clients keep their bindings. Every mutation on an allocation bumps its | ... | @@ -130,6 +130,30 @@ and clients keep their bindings. Every mutation on an allocation bumps its |
| 130 | `generation`, and deletion locks the row at the generation it claimed, so a | 130 | `generation`, and deletion locks the row at the generation it claimed, so a |
| 131 | host that reconnects mid-sweep wins. | 131 | host that reconnects mid-sweep wins. |
| 132 | 132 | ||
| 133 | ## The relay client follows the server's pin | ||
| 134 | |||
| 135 | The host runs `cloudflared` pinned by `CLOUDFLARED_VERSION` in | ||
| 136 | [`relayClient.ts`](../../packages/shared/src/relayClient.ts), and bumping it there | ||
| 137 | (version, URLs, and checksums) is the whole release step. A linked host that | ||
| 138 | starts a server with a new pin keeps its connector up on the newest older managed | ||
| 139 | release, installs the pinned one in the background, then restarts only the | ||
| 140 | connector child on it. If that restart fails to spawn, the host requests recovery | ||
| 141 | as it would for an exited connector. A host with no relay client installs one | ||
| 142 | first. A `cloudflared` on `PATH` or an explicit `T3CODE_CLOUDFLARED_PATH` is the | ||
| 143 | user's choice and is never replaced, since linking asks before downloading. | ||
| 144 | |||
| 145 | Once the pinned release registers a connection, managed releases older than the | ||
| 146 | pin are deleted except the newest of them. That one, and any newer release, can | ||
| 147 | belong to another server sharing the same T3 home, such as a rollback after a | ||
| 148 | failed update, which could not run again without it. | ||
| 149 | |||
| 150 | The version comes from running `cloudflared version`, not from the folder name. | ||
| 151 | Connectors from before `--no-autoupdate` replaced themselves in place, so a | ||
| 152 | managed folder can hold another release. Such a binary still runs as a fallback | ||
| 153 | while the pin installs. Every binary needs `CLOUDFLARED_MIN_VERSION`, the oldest | ||
| 154 | release that accepts every flag the connector is started with. Raise it whenever | ||
| 155 | a new flag is added. | ||
| 156 | |||
| 133 | ## OAuth traps | 157 | ## OAuth traps |
| 134 | 158 | ||
| 135 | Interactive clients and the headless CLI use the same Clerk application but | 159 | Interactive clients and the headless CLI use the same Clerk application but |
packages/shared/src/relayClient.test.ts+213-10| ... | @@ -25,7 +25,8 @@ const layerHostRuntime = (env: Record<string, string> = {}) => | ... | @@ -25,7 +25,8 @@ const layerHostRuntime = (env: Record<string, string> = {}) => |
| 25 | ConfigProvider.layer(ConfigProvider.fromEnv({ env })), | 25 | ConfigProvider.layer(ConfigProvider.fromEnv({ env })), |
| 26 | ); | 26 | ); |
| 27 | 27 | ||
| 28 | function makeHandle(exitCode = 0) { | 28 | function makeHandle(exitCode = 0, output = "") { |
| 29 | const stdout = output ? Stream.make(new TextEncoder().encode(output)) : Stream.empty; | ||
| 29 | return ChildProcessSpawner.makeHandle({ | 30 | return ChildProcessSpawner.makeHandle({ |
| 30 | pid: ChildProcessSpawner.ProcessId(100), | 31 | pid: ChildProcessSpawner.ProcessId(100), |
| 31 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), | 32 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), |
| ... | @@ -33,9 +34,9 @@ function makeHandle(exitCode = 0) { | ... | @@ -33,9 +34,9 @@ function makeHandle(exitCode = 0) { |
| 33 | kill: () => Effect.void, | 34 | kill: () => Effect.void, |
| 34 | unref: Effect.succeed(Effect.void), | 35 | unref: Effect.succeed(Effect.void), |
| 35 | stdin: Sink.drain, | 36 | stdin: Sink.drain, |
| 36 | stdout: Stream.empty, | 37 | stdout, |
| 37 | stderr: Stream.empty, | 38 | stderr: Stream.empty, |
| 38 | all: Stream.empty, | 39 | all: stdout, |
| 39 | getInputFd: () => Sink.drain, | 40 | getInputFd: () => Sink.drain, |
| 40 | getOutputFd: () => Stream.empty, | 41 | getOutputFd: () => Stream.empty, |
| 41 | }); | 42 | }); |
| ... | @@ -51,20 +52,45 @@ const layerHttpClient = (bytes: Uint8Array) => | ... | @@ -51,20 +52,45 @@ const layerHttpClient = (bytes: Uint8Array) => |
| 51 | ), | 52 | ), |
| 52 | ); | 53 | ); |
| 53 | 54 | ||
| 54 | const layerSpawner = (commands: Array<string>) => | 55 | // Answers `cloudflared version` with the version recorded for that path, which |
| 56 | // defaults to the pinned release; tests change it to simulate other binaries. | ||
| 57 | const layerSpawner = (commands: Array<string>, versions: Record<string, string> = {}) => | ||
| 55 | Layer.succeed( | 58 | Layer.succeed( |
| 56 | ChildProcessSpawner.ChildProcessSpawner, | 59 | ChildProcessSpawner.ChildProcessSpawner, |
| 57 | ChildProcessSpawner.make((command) => | 60 | ChildProcessSpawner.make((command) => |
| 58 | Effect.sync(() => { | 61 | Effect.sync(() => { |
| 59 | commands.push(ChildProcess.isStandardCommand(command) ? command.command : "piped-command"); | 62 | if (!ChildProcess.isStandardCommand(command)) { |
| 60 | // The pinned Windows executable rejects --version but accepts the version subcommand. | 63 | commands.push("piped-command"); |
| 61 | return makeHandle( | 64 | return makeHandle(); |
| 62 | ChildProcess.isStandardCommand(command) && command.args.includes("--version") ? 1 : 0, | 65 | } |
| 63 | ); | 66 | commands.push(command.command); |
| 67 | if (command.args[0] === "version") { | ||
| 68 | const version = versions[command.command] ?? RelayClient.CLOUDFLARED_VERSION; | ||
| 69 | return makeHandle(0, `cloudflared version ${version} (built 2026-01-01-00:00 UTC)\n`); | ||
| 70 | } | ||
| 71 | return makeHandle(); | ||
| 64 | }), | 72 | }), |
| 65 | ), | 73 | ), |
| 66 | ); | 74 | ); |
| 67 | 75 | ||
| 76 | const writeExecutable = (filePath: string, contents = "cloudflared") => | ||
| 77 | Effect.gen(function* () { | ||
| 78 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 79 | yield* fileSystem.makeDirectory(filePath.slice(0, filePath.lastIndexOf("/")), { | ||
| 80 | recursive: true, | ||
| 81 | }); | ||
| 82 | yield* fileSystem.writeFileString(filePath, contents); | ||
| 83 | yield* fileSystem.chmod(filePath, 0o755); | ||
| 84 | }); | ||
| 85 | |||
| 86 | type RelayClientTestServices = | ||
| 87 | | Layer.Success<ReturnType<typeof layerHostRuntime> | typeof NodeServices.layer> | ||
| 88 | | HttpClient.HttpClient | ||
| 89 | | ChildProcessSpawner.ChildProcessSpawner; | ||
| 90 | |||
| 91 | const managedPathFor = (baseDir: string, version: string) => | ||
| 92 | `${baseDir}/tools/cloudflared/${version}/linux-x64/cloudflared`; | ||
| 93 | |||
| 68 | describe("RelayClient", () => { | 94 | describe("RelayClient", () => { |
| 69 | it.effect.skipIf(windowsHost)( | 95 | it.effect.skipIf(windowsHost)( |
| 70 | "resolves explicit overrides before managed and PATH executables", | 96 | "resolves explicit overrides before managed and PATH executables", |
| ... | @@ -220,7 +246,9 @@ describe("RelayClient", () => { | ... | @@ -220,7 +246,9 @@ describe("RelayClient", () => { |
| 220 | concurrency: "unbounded", | 246 | concurrency: "unbounded", |
| 221 | }); | 247 | }); |
| 222 | expect(second).toEqual(first); | 248 | expect(second).toEqual(first); |
| 223 | expect(commands).toHaveLength(1); | 249 | // The first install validates the download once; the second, after waiting |
| 250 | // its turn, probes the activated binary and reuses it instead of downloading. | ||
| 251 | expect(commands).toHaveLength(2); | ||
| 224 | }).pipe( | 252 | }).pipe( |
| 225 | Effect.scoped, | 253 | Effect.scoped, |
| 226 | Effect.provide( | 254 | Effect.provide( |
| ... | @@ -278,4 +306,179 @@ describe("RelayClient", () => { | ... | @@ -278,4 +306,179 @@ describe("RelayClient", () => { |
| 278 | ); | 306 | ); |
| 279 | }, | 307 | }, |
| 280 | ); | 308 | ); |
| 309 | |||
| 310 | describe("version selection", () => { | ||
| 311 | const run = <A, E>( | ||
| 312 | body: (baseDir: string) => Effect.Effect<A, E, RelayClientTestServices>, | ||
| 313 | options: { readonly versions?: Record<string, string> } = {}, | ||
| 314 | ) => | ||
| 315 | Effect.gen(function* () { | ||
| 316 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 317 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 318 | prefix: "t3-cloudflared-test-", | ||
| 319 | }); | ||
| 320 | return yield* body(baseDir); | ||
| 321 | }).pipe( | ||
| 322 | Effect.scoped, | ||
| 323 | Effect.provide( | ||
| 324 | Layer.mergeAll( | ||
| 325 | NodeServices.layer, | ||
| 326 | layerHttpClient(new Uint8Array()), | ||
| 327 | layerSpawner([], options.versions), | ||
| 328 | layerHostRuntime({ PATH: "" }), | ||
| 329 | ), | ||
| 330 | ), | ||
| 331 | ); | ||
| 332 | |||
| 333 | it.effect.skipIf(windowsHost)("skips a PATH binary older than the minimum version", () => { | ||
| 334 | const versions: Record<string, string> = {}; | ||
| 335 | return run( | ||
| 336 | (baseDir) => | ||
| 337 | Effect.gen(function* () { | ||
| 338 | const oldPath = `${baseDir}/old/cloudflared`; | ||
| 339 | const newPath = `${baseDir}/new/cloudflared`; | ||
| 340 | yield* writeExecutable(oldPath); | ||
| 341 | yield* writeExecutable(newPath); | ||
| 342 | versions[oldPath] = "2023.8.2"; | ||
| 343 | versions[newPath] = RelayClient.CLOUDFLARED_MIN_VERSION; | ||
| 344 | const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }); | ||
| 345 | const env = { PATH: `${baseDir}/old:${baseDir}/new` }; | ||
| 346 | expect( | ||
| 347 | yield* manager.resolve.pipe( | ||
| 348 | Effect.provideService( | ||
| 349 | ConfigProvider.ConfigProvider, | ||
| 350 | ConfigProvider.fromEnv({ env }), | ||
| 351 | ), | ||
| 352 | ), | ||
| 353 | ).toEqual({ | ||
| 354 | status: "available", | ||
| 355 | executablePath: newPath, | ||
| 356 | source: "path", | ||
| 357 | version: RelayClient.CLOUDFLARED_MIN_VERSION, | ||
| 358 | }); | ||
| 359 | }), | ||
| 360 | { versions }, | ||
| 361 | ); | ||
| 362 | }); | ||
| 363 | |||
| 364 | it.effect.skipIf(windowsHost)("reports an outdated override as missing", () => { | ||
| 365 | const versions: Record<string, string> = {}; | ||
| 366 | return run( | ||
| 367 | (baseDir) => | ||
| 368 | Effect.gen(function* () { | ||
| 369 | const overridePath = `${baseDir}/override/cloudflared`; | ||
| 370 | yield* writeExecutable(overridePath); | ||
| 371 | versions[overridePath] = "2025.6.0"; | ||
| 372 | const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }); | ||
| 373 | const env = { PATH: "", T3CODE_CLOUDFLARED_PATH: overridePath }; | ||
| 374 | const withEnv = Effect.provideService( | ||
| 375 | ConfigProvider.ConfigProvider, | ||
| 376 | ConfigProvider.fromEnv({ env }), | ||
| 377 | ); | ||
| 378 | expect(yield* manager.resolve.pipe(withEnv)).toEqual({ | ||
| 379 | status: "missing", | ||
| 380 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 381 | }); | ||
| 382 | const error = yield* manager.install.pipe(withEnv, Effect.flip); | ||
| 383 | expect(error.reason).toBe("override_missing"); | ||
| 384 | }), | ||
| 385 | { versions }, | ||
| 386 | ); | ||
| 387 | }); | ||
| 388 | |||
| 389 | it.effect.skipIf(windowsHost)( | ||
| 390 | "prefers the pinned release, then the newest older managed release, then PATH", | ||
| 391 | () => { | ||
| 392 | const versions: Record<string, string> = {}; | ||
| 393 | return run( | ||
| 394 | (baseDir) => | ||
| 395 | Effect.gen(function* () { | ||
| 396 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 397 | const pathBinary = `${baseDir}/bin/cloudflared`; | ||
| 398 | const older = managedPathFor(baseDir, "2025.9.0"); | ||
| 399 | const newer = managedPathFor(baseDir, "2026.1.0"); | ||
| 400 | const pinned = managedPathFor(baseDir, RelayClient.CLOUDFLARED_VERSION); | ||
| 401 | // A newer server sharing this base dir owns this folder. | ||
| 402 | const newerServer = managedPathFor(baseDir, "2099.1.0"); | ||
| 403 | versions[newerServer] = "2099.1.0"; | ||
| 404 | yield* writeExecutable(newerServer); | ||
| 405 | versions[pathBinary] = "2026.9.3"; | ||
| 406 | versions[older] = "2025.9.0"; | ||
| 407 | versions[newer] = "2026.1.0"; | ||
| 408 | yield* writeExecutable(pathBinary); | ||
| 409 | yield* writeExecutable(older); | ||
| 410 | yield* writeExecutable(newer); | ||
| 411 | const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }); | ||
| 412 | const resolveWith = manager.resolve.pipe( | ||
| 413 | Effect.provideService( | ||
| 414 | ConfigProvider.ConfigProvider, | ||
| 415 | ConfigProvider.fromEnv({ env: { PATH: `${baseDir}/bin` } }), | ||
| 416 | ), | ||
| 417 | ); | ||
| 418 | |||
| 419 | expect(yield* resolveWith).toMatchObject({ source: "managed", version: "2026.1.0" }); | ||
| 420 | |||
| 421 | yield* writeExecutable(pinned); | ||
| 422 | expect(yield* resolveWith).toMatchObject({ | ||
| 423 | executablePath: pinned, | ||
| 424 | source: "managed", | ||
| 425 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 426 | }); | ||
| 427 | |||
| 428 | yield* manager.pruneManagedVersions; | ||
| 429 | expect( | ||
| 430 | (yield* fileSystem.readDirectory(`${baseDir}/tools/cloudflared`)).sort(), | ||
| 431 | ).toEqual(["2026.1.0", RelayClient.CLOUDFLARED_VERSION, "2099.1.0"]); | ||
| 432 | |||
| 433 | // The kept older release still outranks PATH; PATH is the last resort. | ||
| 434 | yield* fileSystem.remove(pinned); | ||
| 435 | expect(yield* resolveWith).toMatchObject({ source: "managed", version: "2026.1.0" }); | ||
| 436 | yield* fileSystem.remove(`${baseDir}/tools/cloudflared/2026.1.0`, { | ||
| 437 | recursive: true, | ||
| 438 | }); | ||
| 439 | expect(yield* resolveWith).toMatchObject({ source: "path", version: "2026.9.3" }); | ||
| 440 | }), | ||
| 441 | { versions }, | ||
| 442 | ); | ||
| 443 | }, | ||
| 444 | ); | ||
| 445 | |||
| 446 | it.effect.skipIf(windowsHost)( | ||
| 447 | "keeps a self-updated managed binary as a fallback but still installs the pin", | ||
| 448 | () => { | ||
| 449 | const versions: Record<string, string> = {}; | ||
| 450 | return run( | ||
| 451 | (baseDir) => | ||
| 452 | Effect.gen(function* () { | ||
| 453 | const pinned = managedPathFor(baseDir, RelayClient.CLOUDFLARED_VERSION); | ||
| 454 | versions[pinned] = "2026.9.3"; | ||
| 455 | yield* writeExecutable(pinned); | ||
| 456 | const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir }); | ||
| 457 | const resolved = yield* manager.resolve; | ||
| 458 | expect(resolved).toEqual({ | ||
| 459 | status: "available", | ||
| 460 | executablePath: pinned, | ||
| 461 | source: "managed", | ||
| 462 | version: "2026.9.3", | ||
| 463 | }); | ||
| 464 | expect( | ||
| 465 | resolved.status === "available" && RelayClient.isPinnedManagedRelayClient(resolved), | ||
| 466 | ).toBe(false); | ||
| 467 | }), | ||
| 468 | { versions }, | ||
| 469 | ); | ||
| 470 | }, | ||
| 471 | ); | ||
| 472 | }); | ||
| 473 | |||
| 474 | it("orders and parses cloudflared versions", () => { | ||
| 475 | expect( | ||
| 476 | RelayClient.parseCloudflaredVersionOutput( | ||
| 477 | "cloudflared version 2026.5.2 (built 2026-05-20-10:00 UTC)", | ||
| 478 | ), | ||
| 479 | ).toBe("2026.5.2"); | ||
| 480 | expect(RelayClient.parseCloudflaredVersionOutput("Incorrect Usage")).toBeNull(); | ||
| 481 | expect(RelayClient.compareCloudflaredVersions("2025.10.0", "2025.6.1")).toBeGreaterThan(0); | ||
| 482 | expect(RelayClient.compareCloudflaredVersions("2023.8.2", "2025.6.1")).toBeLessThan(0); | ||
| 483 | }); | ||
| 281 | }); | 484 | }); |
packages/shared/src/relayClient.ts+197-49| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as Cache from "effect/Cache"; | ||
| 1 | import * as Clock from "effect/Clock"; | 2 | import * as Clock from "effect/Clock"; |
| 2 | import type { | 3 | import type { |
| 3 | RelayClientInstallProgressEvent, | 4 | RelayClientInstallProgressEvent, |
| ... | @@ -7,7 +8,9 @@ import * as Config from "effect/Config"; | ... | @@ -7,7 +8,9 @@ import * as Config from "effect/Config"; |
| 7 | import * as Context from "effect/Context"; | 8 | import * as Context from "effect/Context"; |
| 8 | import * as Crypto from "effect/Crypto"; | 9 | import * as Crypto from "effect/Crypto"; |
| 9 | import * as Data from "effect/Data"; | 10 | import * as Data from "effect/Data"; |
| 11 | import * as Duration from "effect/Duration"; | ||
| 10 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Exit from "effect/Exit"; | ||
| 11 | import * as Hex from "effect/encoding/Hex"; | 14 | import * as Hex from "effect/encoding/Hex"; |
| 12 | import * as FileSystem from "effect/FileSystem"; | 15 | import * as FileSystem from "effect/FileSystem"; |
| 13 | import * as Layer from "effect/Layer"; | 16 | import * as Layer from "effect/Layer"; |
| ... | @@ -20,6 +23,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; | ... | @@ -20,6 +23,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 20 | import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; | 23 | import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; |
| 21 | 24 | ||
| 22 | export const CLOUDFLARED_VERSION = "2026.5.2"; | 25 | export const CLOUDFLARED_VERSION = "2026.5.2"; |
| 26 | // The oldest release that accepts every flag the connector is started with | ||
| 27 | // (`--output` arrived in 2025.6.1). Override, PATH, and older managed binaries | ||
| 28 | // below it are skipped, since they exit immediately on the unknown flag. | ||
| 29 | export const CLOUDFLARED_MIN_VERSION = "2025.6.1"; | ||
| 23 | const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; | 30 | const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; |
| 24 | 31 | ||
| 25 | export type RelayClientExecutableSource = "override" | "managed" | "path"; | 32 | export type RelayClientExecutableSource = "override" | "managed" | "path"; |
| ... | @@ -96,11 +103,18 @@ const CLOUDFLARED_RELEASE_ASSETS: Readonly< | ... | @@ -96,11 +103,18 @@ const CLOUDFLARED_RELEASE_ASSETS: Readonly< |
| 96 | sha256: "20b9638f685333d623798e733effbad2487093f15ba592f6c7752360ff3b7ab7", | 103 | sha256: "20b9638f685333d623798e733effbad2487093f15ba592f6c7752360ff3b7ab7", |
| 97 | archive: "binary", | 104 | archive: "binary", |
| 98 | }, | 105 | }, |
| 106 | // Cloudflare publishes no Windows ARM64 build; Windows 11 on ARM runs x64 under emulation. | ||
| 107 | "win32-arm64": { | ||
| 108 | url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-windows-amd64.exe", | ||
| 109 | sha256: "20b9638f685333d623798e733effbad2487093f15ba592f6c7752360ff3b7ab7", | ||
| 110 | archive: "binary", | ||
| 111 | }, | ||
| 99 | }; | 112 | }; |
| 100 | 113 | ||
| 101 | const INSTALL_LOCK_RETRY_COUNT = 100; | 114 | const INSTALL_LOCK_RETRY_COUNT = 100; |
| 102 | const INSTALL_LOCK_RETRY_DELAY = "100 millis"; | 115 | const INSTALL_LOCK_RETRY_DELAY = "100 millis"; |
| 103 | const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; | 116 | const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; |
| 117 | const VERSION_PROBE_TIMEOUT = "10 seconds"; | ||
| 104 | 118 | ||
| 105 | const trimmedString = (name: string) => | 119 | const trimmedString = (name: string) => |
| 106 | Config.String(name).pipe( | 120 | Config.String(name).pipe( |
| ... | @@ -124,11 +138,25 @@ export interface CloudflaredRelayClientOptions { | ... | @@ -124,11 +138,25 @@ export interface CloudflaredRelayClientOptions { |
| 124 | } | 138 | } |
| 125 | 139 | ||
| 126 | export interface RelayClientShape { | 140 | export interface RelayClientShape { |
| 141 | /** | ||
| 142 | * Finds the relay client to run, without downloading anything: the override, | ||
| 143 | * else the pinned managed folder, else the newest older managed folder, else | ||
| 144 | * `cloudflared` on PATH. Every candidate must report a compatible version, and | ||
| 145 | * the status carries the version the binary reports. | ||
| 146 | */ | ||
| 127 | readonly resolve: Effect.Effect<RelayClientStatus>; | 147 | readonly resolve: Effect.Effect<RelayClientStatus>; |
| 148 | /** Installs the pinned managed release unless it, or a valid override, is already present. */ | ||
| 128 | readonly install: Effect.Effect<AvailableRelayClient, RelayClientInstallError>; | 149 | readonly install: Effect.Effect<AvailableRelayClient, RelayClientInstallError>; |
| 129 | readonly installWithProgress: ( | 150 | readonly installWithProgress: ( |
| 130 | report: (event: RelayClientInstallProgressEvent) => Effect.Effect<void>, | 151 | report: (event: RelayClientInstallProgressEvent) => Effect.Effect<void>, |
| 131 | ) => Effect.Effect<AvailableRelayClient, RelayClientInstallError>; | 152 | ) => Effect.Effect<AvailableRelayClient, RelayClientInstallError>; |
| 153 | /** Removes managed releases older than the pin, except the newest one. Call once the pin has connected. */ | ||
| 154 | readonly pruneManagedVersions: Effect.Effect<void>; | ||
| 155 | } | ||
| 156 | |||
| 157 | /** True when this is the pinned managed release, which needs no update. */ | ||
| 158 | export function isPinnedManagedRelayClient(client: AvailableRelayClient): boolean { | ||
| 159 | return client.source === "managed" && client.version === CLOUDFLARED_VERSION; | ||
| 132 | } | 160 | } |
| 133 | 161 | ||
| 134 | export class RelayClient extends Context.Service<RelayClient, RelayClientShape>()( | 162 | export class RelayClient extends Context.Service<RelayClient, RelayClientShape>()( |
| ... | @@ -146,6 +174,33 @@ function resolveReleaseAsset( | ... | @@ -146,6 +174,33 @@ function resolveReleaseAsset( |
| 146 | return CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`] ?? null; | 174 | return CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`] ?? null; |
| 147 | } | 175 | } |
| 148 | 176 | ||
| 177 | const VERSION_PATTERN = /\b(\d{4})\.(\d{1,2})\.(\d+)\b/u; | ||
| 178 | |||
| 179 | function parseVersion(value: string): readonly [number, number, number] | null { | ||
| 180 | const match = VERSION_PATTERN.exec(value); | ||
| 181 | return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; | ||
| 182 | } | ||
| 183 | |||
| 184 | /** Orders `YYYY.M.P` cloudflared versions; unparseable versions sort first. */ | ||
| 185 | export function compareCloudflaredVersions(left: string, right: string): number { | ||
| 186 | const a = parseVersion(left) ?? [0, 0, 0]; | ||
| 187 | const b = parseVersion(right) ?? [0, 0, 0]; | ||
| 188 | return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; | ||
| 189 | } | ||
| 190 | |||
| 191 | /** Reads the version from `cloudflared version` output, e.g. `cloudflared version 2026.5.2 (built ...)`. */ | ||
| 192 | export function parseCloudflaredVersionOutput(output: string): string | null { | ||
| 193 | const version = parseVersion(output); | ||
| 194 | return version ? version.join(".") : null; | ||
| 195 | } | ||
| 196 | |||
| 197 | // A changed file is a new key, so a replaced binary is probed again. | ||
| 198 | class VersionProbeKey extends Data.Class<{ | ||
| 199 | readonly executablePath: string; | ||
| 200 | readonly size: number; | ||
| 201 | readonly mtimeMillis: number; | ||
| 202 | }> {} | ||
| 203 | |||
| 149 | function isAlreadyExists(error: PlatformError.PlatformError): boolean { | 204 | function isAlreadyExists(error: PlatformError.PlatformError): boolean { |
| 150 | return error.reason._tag === "AlreadyExists"; | 205 | return error.reason._tag === "AlreadyExists"; |
| 151 | } | 206 | } |
| ... | @@ -190,14 +245,10 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function | ... | @@ -190,14 +245,10 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function |
| 190 | const arch = yield* HostProcessArchitecture; | 245 | const arch = yield* HostProcessArchitecture; |
| 191 | const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch); | 246 | const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch); |
| 192 | const loadCloudflaredConfig = Effect.suspend(() => CloudflaredConfig).pipe(Effect.orDie); | 247 | const loadCloudflaredConfig = Effect.suspend(() => CloudflaredConfig).pipe(Effect.orDie); |
| 193 | const managedPath = path.join( | 248 | const managedRoot = path.join(options.baseDir, "tools", "cloudflared"); |
| 194 | options.baseDir, | 249 | const managedPathFor = (version: string) => |
| 195 | "tools", | 250 | path.join(managedRoot, version, `${platform}-${arch}`, executableFileName(platform)); |
| 196 | "cloudflared", | 251 | const managedPath = managedPathFor(CLOUDFLARED_VERSION); |
| 197 | CLOUDFLARED_VERSION, | ||
| 198 | `${platform}-${arch}`, | ||
| 199 | executableFileName(platform), | ||
| 200 | ); | ||
| 201 | 252 | ||
| 202 | const isExecutableFile = Effect.fn("cloudflared.isExecutableFile")(function* ( | 253 | const isExecutableFile = Effect.fn("cloudflared.isExecutableFile")(function* ( |
| 203 | executablePath: string, | 254 | executablePath: string, |
| ... | @@ -207,6 +258,78 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function | ... | @@ -207,6 +258,78 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function |
| 207 | return platform === "win32" || (info.value.mode & 0o111) !== 0; | 258 | return platform === "win32" || (info.value.mode & 0o111) !== 0; |
| 208 | }); | 259 | }); |
| 209 | 260 | ||
| 261 | // `cloudflared version` costs a process spawn, so answers are cached per file | ||
| 262 | // revision. Before `--no-autoupdate`, a managed binary could replace itself in | ||
| 263 | // place, so the version is read from the binary, never from its folder name. | ||
| 264 | // A failed or timed-out probe is not cached, so a slow spawn under an AV scan | ||
| 265 | // does not hide a good binary until it changes on disk. | ||
| 266 | const versionCache = yield* Cache.makeWith( | ||
| 267 | (key: VersionProbeKey) => | ||
| 268 | spawner | ||
| 269 | .string( | ||
| 270 | ChildProcess.make(key.executablePath, ["version"], { stdin: "ignore", stderr: "ignore" }), | ||
| 271 | ) | ||
| 272 | .pipe( | ||
| 273 | Effect.map(parseCloudflaredVersionOutput), | ||
| 274 | Effect.timeoutOption(VERSION_PROBE_TIMEOUT), | ||
| 275 | Effect.map(Option.getOrNull), | ||
| 276 | Effect.orElseSucceed(() => null), | ||
| 277 | ), | ||
| 278 | { | ||
| 279 | capacity: 16, | ||
| 280 | timeToLive: (exit) => | ||
| 281 | Exit.isSuccess(exit) && exit.value !== null ? Duration.infinity : Duration.zero, | ||
| 282 | }, | ||
| 283 | ); | ||
| 284 | const probeVersion = Effect.fn("cloudflared.probeVersion")(function* (executablePath: string) { | ||
| 285 | const info = yield* fileSystem.stat(executablePath).pipe(Effect.option); | ||
| 286 | if (Option.isNone(info) || info.value.type !== "File") return null; | ||
| 287 | if (platform !== "win32" && (info.value.mode & 0o111) === 0) return null; | ||
| 288 | return yield* Cache.get( | ||
| 289 | versionCache, | ||
| 290 | new VersionProbeKey({ | ||
| 291 | executablePath, | ||
| 292 | size: Number(info.value.size), | ||
| 293 | mtimeMillis: Option.getOrUndefined(info.value.mtime)?.getTime() ?? 0, | ||
| 294 | }), | ||
| 295 | ); | ||
| 296 | }); | ||
| 297 | |||
| 298 | const compatibleCandidate = Effect.fn("cloudflared.compatibleCandidate")(function* ( | ||
| 299 | executablePath: string, | ||
| 300 | source: RelayClientExecutableSource, | ||
| 301 | expectedVersion?: string, | ||
| 302 | ) { | ||
| 303 | const version = yield* probeVersion(executablePath); | ||
| 304 | if (version === null || compareCloudflaredVersions(version, CLOUDFLARED_MIN_VERSION) < 0) { | ||
| 305 | return null; | ||
| 306 | } | ||
| 307 | if (expectedVersion !== undefined && version !== expectedVersion) { | ||
| 308 | yield* Effect.logWarning("Ignoring a managed relay client that reports another version", { | ||
| 309 | executablePath, | ||
| 310 | expectedVersion, | ||
| 311 | version, | ||
| 312 | }); | ||
| 313 | return null; | ||
| 314 | } | ||
| 315 | return { status: "available", executablePath, source, version } satisfies AvailableRelayClient; | ||
| 316 | }); | ||
| 317 | |||
| 318 | // Managed releases older than the pin, newest first. Newer folders belong to | ||
| 319 | // a newer server sharing this base dir and are never used or pruned here. | ||
| 320 | const olderManagedVersions = Effect.gen(function* () { | ||
| 321 | const entries = yield* fileSystem | ||
| 322 | .readDirectory(managedRoot) | ||
| 323 | .pipe(Effect.orElseSucceed(() => [])); | ||
| 324 | return entries | ||
| 325 | .filter( | ||
| 326 | (entry) => | ||
| 327 | parseVersion(entry) !== null && | ||
| 328 | compareCloudflaredVersions(entry, CLOUDFLARED_VERSION) < 0, | ||
| 329 | ) | ||
| 330 | .sort((left, right) => compareCloudflaredVersions(right, left)); | ||
| 331 | }); | ||
| 332 | |||
| 210 | const resolvePathExecutable = Effect.gen(function* () { | 333 | const resolvePathExecutable = Effect.gen(function* () { |
| 211 | const config = yield* loadCloudflaredConfig; | 334 | const config = yield* loadCloudflaredConfig; |
| 212 | const pathValue = Option.getOrUndefined(config.path); | 335 | const pathValue = Option.getOrUndefined(config.path); |
| ... | @@ -216,49 +339,63 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function | ... | @@ -216,49 +339,63 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function |
| 216 | const trimmed = directory.trim().replace(/^"|"$/gu, ""); | 339 | const trimmed = directory.trim().replace(/^"|"$/gu, ""); |
| 217 | if (trimmed.length === 0) continue; | 340 | if (trimmed.length === 0) continue; |
| 218 | const candidate = path.join(trimmed, executableFileName(platform)); | 341 | const candidate = path.join(trimmed, executableFileName(platform)); |
| 219 | if (yield* isExecutableFile(candidate)) return candidate; | 342 | if (!(yield* isExecutableFile(candidate))) continue; |
| 343 | const available = yield* compatibleCandidate(candidate, "path"); | ||
| 344 | if (available) return available; | ||
| 345 | yield* Effect.logWarning("Skipping an incompatible relay client on PATH", { | ||
| 346 | executablePath: candidate, | ||
| 347 | minimumVersion: CLOUDFLARED_MIN_VERSION, | ||
| 348 | }); | ||
| 220 | } | 349 | } |
| 221 | return null; | 350 | return null; |
| 222 | }); | 351 | }); |
| 223 | 352 | ||
| 353 | const missingStatus: RelayClientStatus = { status: "missing", version: CLOUDFLARED_VERSION }; | ||
| 354 | const unsupportedStatus: RelayClientStatus = { | ||
| 355 | status: "unsupported", | ||
| 356 | platform, | ||
| 357 | arch, | ||
| 358 | version: CLOUDFLARED_VERSION, | ||
| 359 | }; | ||
| 224 | const resolve: RelayClientShape["resolve"] = Effect.gen(function* () { | 360 | const resolve: RelayClientShape["resolve"] = Effect.gen(function* () { |
| 225 | const config = yield* loadCloudflaredConfig; | 361 | const config = yield* loadCloudflaredConfig; |
| 226 | if (Option.isSome(config.executableOverride)) { | 362 | if (Option.isSome(config.executableOverride)) { |
| 227 | return (yield* isExecutableFile(config.executableOverride.value)) | 363 | const override = yield* compatibleCandidate(config.executableOverride.value, "override"); |
| 228 | ? { | 364 | if (override) return override; |
| 229 | status: "available", | 365 | yield* Effect.logWarning( |
| 230 | executablePath: config.executableOverride.value, | 366 | `${CLOUDFLARED_PATH_ENV_NAME} must point to cloudflared ${CLOUDFLARED_MIN_VERSION} or newer`, |
| 231 | source: "override", | 367 | { executablePath: config.executableOverride.value }, |
| 232 | version: CLOUDFLARED_VERSION, | 368 | ); |
| 233 | } | 369 | return missingStatus; |
| 234 | : { status: "missing", version: CLOUDFLARED_VERSION }; | ||
| 235 | } | 370 | } |
| 236 | if (yield* isExecutableFile(managedPath)) { | 371 | // A managed binary reports the version it actually runs. One that replaced |
| 237 | return { | 372 | // itself in place still serves as a fallback; the runtime sees it is not the |
| 238 | status: "available", | 373 | // pinned release and installs that. |
| 239 | executablePath: managedPath, | 374 | const pinned = yield* compatibleCandidate(managedPath, "managed"); |
| 240 | source: "managed", | 375 | if (pinned) return pinned; |
| 241 | version: CLOUDFLARED_VERSION, | 376 | for (const version of yield* olderManagedVersions) { |
| 242 | }; | 377 | const older = yield* compatibleCandidate(managedPathFor(version), "managed"); |
| 378 | if (older) return older; | ||
| 243 | } | 379 | } |
| 244 | const pathExecutable = yield* resolvePathExecutable; | 380 | const pathExecutable = yield* resolvePathExecutable; |
| 245 | if (pathExecutable) { | 381 | if (pathExecutable) return pathExecutable; |
| 246 | return { | 382 | return releaseAsset ? missingStatus : unsupportedStatus; |
| 247 | status: "available", | 383 | }).pipe(Effect.withSpan("cloudflared.resolve")); |
| 248 | executablePath: pathExecutable, | 384 | |
| 249 | source: "path", | 385 | const pruneManagedVersions: RelayClientShape["pruneManagedVersions"] = Effect.gen(function* () { |
| 250 | version: CLOUDFLARED_VERSION, | 386 | // The newest older release stays: an older server sharing this base dir, or a |
| 251 | }; | 387 | // rollback after a failed update, may still need it and cannot redownload it. |
| 388 | for (const version of (yield* olderManagedVersions).slice(1)) { | ||
| 389 | // A connector from another server sharing this base dir may still run an | ||
| 390 | // older release; Windows refuses to delete it, so the next prune retries. | ||
| 391 | yield* fileSystem.remove(path.join(managedRoot, version), { recursive: true }).pipe( | ||
| 392 | Effect.tap(() => Effect.logInfo("Removed an older managed relay client", { version })), | ||
| 393 | Effect.catch((cause) => | ||
| 394 | Effect.logDebug("Could not remove an older managed relay client", { version, cause }), | ||
| 395 | ), | ||
| 396 | ); | ||
| 252 | } | 397 | } |
| 253 | return releaseAsset | 398 | }).pipe(Effect.withSpan("cloudflared.pruneManagedVersions")); |
| 254 | ? { status: "missing", version: CLOUDFLARED_VERSION } | ||
| 255 | : { | ||
| 256 | status: "unsupported", | ||
| 257 | platform, | ||
| 258 | arch, | ||
| 259 | version: CLOUDFLARED_VERSION, | ||
| 260 | }; | ||
| 261 | }); | ||
| 262 | 399 | ||
| 263 | const runCommand = Effect.fn("cloudflared.runCommand")(function* ( | 400 | const runCommand = Effect.fn("cloudflared.runCommand")(function* ( |
| 264 | command: string, | 401 | command: string, |
| ... | @@ -354,15 +491,17 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function | ... | @@ -354,15 +491,17 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function |
| 354 | report: (stage: RelayClientInstallProgressStage) => Effect.Effect<void>, | 491 | report: (stage: RelayClientInstallProgressStage) => Effect.Effect<void>, |
| 355 | ) { | 492 | ) { |
| 356 | yield* report("checking"); | 493 | yield* report("checking"); |
| 357 | const existing = yield* resolve; | ||
| 358 | if (existing.status === "available") return existing; | ||
| 359 | const config = yield* loadCloudflaredConfig; | 494 | const config = yield* loadCloudflaredConfig; |
| 360 | if (Option.isSome(config.executableOverride)) { | 495 | if (Option.isSome(config.executableOverride)) { |
| 496 | const override = yield* compatibleCandidate(config.executableOverride.value, "override"); | ||
| 497 | if (override) return override; | ||
| 361 | return yield* new RelayClientInstallError({ | 498 | return yield* new RelayClientInstallError({ |
| 362 | reason: "override_missing", | 499 | reason: "override_missing", |
| 363 | message: `${CLOUDFLARED_PATH_ENV_NAME} does not point to an executable file.`, | 500 | message: `${CLOUDFLARED_PATH_ENV_NAME} must point to cloudflared ${CLOUDFLARED_MIN_VERSION} or newer.`, |
| 364 | }); | 501 | }); |
| 365 | } | 502 | } |
| 503 | const existing = yield* compatibleCandidate(managedPath, "managed", CLOUDFLARED_VERSION); | ||
| 504 | if (existing) return existing; | ||
| 366 | if (!releaseAsset) { | 505 | if (!releaseAsset) { |
| 367 | return yield* new RelayClientInstallError({ | 506 | return yield* new RelayClientInstallError({ |
| 368 | reason: "unsupported_platform", | 507 | reason: "unsupported_platform", |
| ... | @@ -391,8 +530,8 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function | ... | @@ -391,8 +530,8 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function |
| 391 | }), | 530 | }), |
| 392 | ); | 531 | ); |
| 393 | return yield* Effect.gen(function* () { | 532 | return yield* Effect.gen(function* () { |
| 394 | const afterLock = yield* resolve; | 533 | const afterLock = yield* compatibleCandidate(managedPath, "managed", CLOUDFLARED_VERSION); |
| 395 | if (afterLock.status === "available") return afterLock; | 534 | if (afterLock) return afterLock; |
| 396 | 535 | ||
| 397 | const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ | 536 | const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ |
| 398 | directory: managedDirectory, | 537 | directory: managedDirectory, |
| ... | @@ -420,9 +559,18 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function | ... | @@ -420,9 +559,18 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function |
| 420 | .pipe(wrapInstallFailure("write_failed", "Could not make the relay client executable.")); | 559 | .pipe(wrapInstallFailure("write_failed", "Could not make the relay client executable.")); |
| 421 | } | 560 | } |
| 422 | yield* report("validating"); | 561 | yield* report("validating"); |
| 423 | yield* runCommand(executablePath, ["version"]).pipe( | 562 | // Requiring the pinned version here keeps a mislabelled asset from being |
| 424 | wrapInstallFailure("validation_failed", "The downloaded relay client binary did not run."), | 563 | // activated, ignored by resolve, and downloaded again on every reconcile. |
| 425 | ); | 564 | const installedVersion = yield* probeVersion(executablePath); |
| 565 | if (installedVersion !== CLOUDFLARED_VERSION) { | ||
| 566 | return yield* new RelayClientInstallError({ | ||
| 567 | reason: "validation_failed", | ||
| 568 | message: | ||
| 569 | installedVersion === null | ||
| 570 | ? "The downloaded relay client binary did not run." | ||
| 571 | : `The downloaded relay client reports version ${installedVersion}, not ${CLOUDFLARED_VERSION}.`, | ||
| 572 | }); | ||
| 573 | } | ||
| 426 | 574 | ||
| 427 | const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`; | 575 | const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`; |
| 428 | yield* report("activating"); | 576 | yield* report("activating"); |
| ... | @@ -468,7 +616,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function | ... | @@ -468,7 +616,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function |
| 468 | ); | 616 | ); |
| 469 | const install = installWithProgress(() => Effect.void); | 617 | const install = installWithProgress(() => Effect.void); |
| 470 | 618 | ||
| 471 | return RelayClient.of({ resolve, install, installWithProgress }); | 619 | return RelayClient.of({ resolve, install, installWithProgress, pruneManagedVersions }); |
| 472 | }); | 620 | }); |
| 473 | 621 | ||
| 474 | export const layerCloudflared = (options: CloudflaredRelayClientOptions) => | 622 | export const layerCloudflared = (options: CloudflaredRelayClientOptions) => |