1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { canonicalCidr } from "../../Utils/ip-address.ts";
9import type { Providers } from "../Providers.ts";
10import type { ClientVpnEndpointId } from "./ClientVpnEndpoint.ts";
11import { retryClientVpn } from "./ClientVpnWait.ts";
12import type { SubnetId } from "./Subnet.ts";
13
14/** Immutable settings for a manually added VPC-based Client VPN route. */
15export interface ClientVpnRouteProps {
16 /** The Client VPN endpoint. Changing this replaces the route. */
17 clientVpnEndpointId: ClientVpnEndpointId;
18 /** The destination IPv4 CIDR. Changing this replaces the route. */
19 destinationCidrBlock: string;
20 /**
21 * The associated target subnet. Pass ClientVpnTargetNetworkAssociation.subnetId
22 * to order creation and deletion after and before the association respectively.
23 * Changing this replaces the route.
24 */
25 targetVpcSubnetId: SubnetId;
26 /** A description of the route. Changing this replaces the route. */
27 description?: string;
28}
29
30/** A manually added route in a Client VPN endpoint's route table. */
31export interface ClientVpnRoute extends Resource<
32 "AWS.EC2.ClientVpnRoute",
33 ClientVpnRouteProps,
34 {
35 /** The Client VPN endpoint containing the route. */
36 clientVpnEndpointId: ClientVpnEndpointId;
37 /** The canonical destination network CIDR. */
38 destinationCidrBlock: string;
39 /** The associated subnet through which traffic is routed. */
40 targetVpcSubnetId: SubnetId;
41 /** The observed route description. */
42 description: string | undefined;
43 /** How AWS created the route: add-route for manual routes, associate for automatic routes. */
44 origin: string | undefined;
45 /** The observed route type. */
46 type: string | undefined;
47 /** The current route state. */
48 status: ec2.ClientVpnRouteStatusCode;
49 /** Additional information about the route state. */
50 statusMessage: string | undefined;
51 },
52 never,
53 Providers
54> {}
55
56/**
57 * Adds a destination route through a Client VPN target subnet. The target must
58 * already be associated; reference the association's subnetId output, not the
59 * subnet resource directly, to preserve deployment and teardown ordering.
60 * Authorization rules are configured separately and are also required for access.
61 *
62 * All property changes require replacement. Description-only changes delete the
63 * old route first because AWS has no modify-route API. AWS-created local routes
64 * cannot be managed or deleted with this resource. This resource manages the
65 * declared endpoint/destination/subnet identity, including an existing manual
66 * route discovered without cached state. Routes have no independent ownership
67 * markers.
68 *
69 * Readiness waits default to 30 minutes. Set `AWS_CLIENT_VPN_TIMEOUT` to a
70 * positive finite duration, such as `45 minutes`, to override this deadline.
71 *
72 * ### Routing Client Traffic
73 * **Example:** Route internet traffic through an associated subnet
74 * ```typescript
75 * const target = yield* AWS.EC2.ClientVpnTargetNetworkAssociation("VpnTarget", {
76 * clientVpnEndpointId: endpoint.clientVpnEndpointId,
77 * subnetId: publicSubnet.subnetId,
78 * });
79 * const route = yield* AWS.EC2.ClientVpnRoute("VpnInternetRoute", {
80 * clientVpnEndpointId: target.clientVpnEndpointId,
81 * targetVpcSubnetId: target.subnetId,
82 * destinationCidrBlock: "0.0.0.0/0",
83 * description: "Internet egress",
84 * });
85 * ```
86 *
87 * The subnet's VPC routing must provide the required egress path. For multiple
88 * target associations, add the same destination through each target subnet so
89 * clients have consistent access regardless of which association serves them.
90 *
91 * @resource
92 */
93export const ClientVpnRoute = Resource<ClientVpnRoute>(
94 "AWS.EC2.ClientVpnRoute",
95);
96
97class ClientVpnRoutePending extends Data.TaggedError("ClientVpnRoutePending")<{
98 message: string;
99}> {}
100
101class ClientVpnRouteFailed extends Data.TaggedError("ClientVpnRouteFailed")<{
102 message: string;
103}> {}
104
105const routes = (clientVpnEndpointId: ClientVpnEndpointId) =>
106 ec2.describeClientVpnRoutes
107 .items({ ClientVpnEndpointId: clientVpnEndpointId })
108 .pipe(
109 Stream.runCollect,
110 Effect.map((items) =>
111 items.filter((route) => route.Status?.Code !== "deleted"),
112 ),
113 Effect.catchTag("InvalidClientVpnEndpointId.NotFound", () =>
114 Effect.succeed([] as ec2.ClientVpnRoute[]),
115 ),
116 );
117
118const findRoute = (props: ClientVpnRouteProps) =>
119 routes(props.clientVpnEndpointId).pipe(
120 Effect.map((items) =>
121 items.find(
122 (route) =>
123 canonicalCidr(route.DestinationCidr) ===
124 canonicalCidr(props.destinationCidrBlock) &&
125 route.TargetSubnet === props.targetVpcSubnetId,
126 ),
127 ),
128 );
129
130const toAttributes = (
131 clientVpnEndpointId: ClientVpnEndpointId,
132 route: ec2.ClientVpnRoute,
133): ClientVpnRoute["Attributes"] => ({
134 clientVpnEndpointId,
135 destinationCidrBlock: route.DestinationCidr!,
136 targetVpcSubnetId: route.TargetSubnet as SubnetId,
137 description: route.Description,
138 origin: route.Origin,
139 type: route.Type,
140 status: route.Status?.Code ?? "creating",
141 statusMessage: route.Status?.Message,
142});
143
144const requireManualRoute = (route: ec2.ClientVpnRoute) =>
145 route.Origin === "add-route"
146 ? Effect.void
147 : Effect.fail(
148 new ClientVpnRouteFailed({
149 message: `Client VPN route ${route.DestinationCidr} has origin ${route.Origin ?? "unknown"}; only manually added routes can be managed.`,
150 }),
151 );
152
153const waitForRoute = (props: ClientVpnRouteProps, deleted: boolean) =>
154 Effect.gen(function* () {
155 const route = yield* findRoute(props);
156 if (deleted && !route) return undefined;
157 if (route) yield* requireManualRoute(route);
158 if (!deleted && route?.Status?.Code === "active") return route;
159 if (!deleted && route?.Status?.Code === "failed") {
160 return yield* new ClientVpnRouteFailed({
161 message: route.Status.Message ?? "Client VPN route creation failed",
162 });
163 }
164 return yield* new ClientVpnRoutePending({
165 message: `Client VPN route ${props.destinationCidrBlock} is ${route?.Status?.Code ?? "not visible"}; waiting for ${deleted ? "deletion" : "active"}`,
166 });
167 }).pipe((effect) =>
168 retryClientVpn(effect, (error) => error._tag === "ClientVpnRoutePending"),
169 );
170
171const removeRoute = Effect.fn(function* (props: ClientVpnRouteProps) {
172 const route = yield* findRoute(props);
173 if (!route) return;
174 yield* requireManualRoute(route);
175 if (route.Status?.Code !== "deleting") {
176 yield* ec2
177 .deleteClientVpnRoute({
178 ClientVpnEndpointId: props.clientVpnEndpointId,
179 DestinationCidrBlock: canonicalCidr(props.destinationCidrBlock),
180 TargetVpcSubnetId: props.targetVpcSubnetId,
181 })
182 .pipe(
183 Effect.catchTag(
184 [
185 "InvalidClientVpnEndpointId.NotFound",
186 "InvalidClientVpnRouteNotFound",
187 ],
188 () => Effect.void,
189 ),
190 (effect) =>
191 retryClientVpn(effect, (error) => error._tag === "IncorrectState"),
192 );
193 }
194 yield* waitForRoute(props, true);
195});
196
197/** Live AWS provider for ClientVpnRoute. */
198export const ClientVpnRouteProvider = () =>
199 Provider.effect(
200 ClientVpnRoute,
201 Effect.gen(function* () {
202 return {
203 stables: [
204 "clientVpnEndpointId",
205 "destinationCidrBlock",
206 "targetVpcSubnetId",
207 ],
208 nuke: {
209 dependsOn: [
210 "AWS.EC2.ClientVpnTargetNetworkAssociation",
211 "AWS.EC2.ClientVpnEndpoint",
212 ],
213 },
214 list: Effect.fn(function* () {
215 const endpoints = yield* ec2.describeClientVpnEndpoints
216 .items({})
217 .pipe(Stream.runCollect);
218 const items = yield* Effect.forEach(endpoints, (endpoint) =>
219 routes(endpoint.ClientVpnEndpointId as ClientVpnEndpointId).pipe(
220 Effect.map((items) =>
221 items
222 .filter(
223 (route) =>
224 route.Origin === "add-route" &&
225 route.TargetSubnet?.startsWith("subnet-"),
226 )
227 .map((route) =>
228 toAttributes(
229 endpoint.ClientVpnEndpointId as ClientVpnEndpointId,
230 route,
231 ),
232 ),
233 ),
234 ),
235 );
236 return items.flat();
237 }),
238 read: Effect.fn(function* ({ olds, output }) {
239 const props = output ?? olds;
240 const route = yield* findRoute(props);
241 if (!route) return undefined;
242 yield* requireManualRoute(route);
243 return toAttributes(props.clientVpnEndpointId, route);
244 }),
245 diff: Effect.fn(function* ({ olds, news }) {
246 if (!isResolved(news)) {
247 return { action: "replace", deleteFirst: true };
248 }
249 const sameKey =
250 news.clientVpnEndpointId === olds.clientVpnEndpointId &&
251 canonicalCidr(news.destinationCidrBlock) ===
252 canonicalCidr(olds.destinationCidrBlock) &&
253 news.targetVpcSubnetId === olds.targetVpcSubnetId;
254 if (
255 !sameKey ||
256 news.destinationCidrBlock !== olds.destinationCidrBlock ||
257 news.description !== olds.description
258 ) {
259 return { action: "replace", deleteFirst: sameKey };
260 }
261 const route = yield* findRoute(news);
262 if (!route) return { action: "update" };
263 yield* requireManualRoute(route);
264 if (
265 (route.Description ?? "") !== (news.description ?? "") ||
266 route.Status?.Code === "failed"
267 ) {
268 return { action: "replace", deleteFirst: true };
269 }
270 if (route.Status?.Code !== "active") return { action: "update" };
271 }),
272 reconcile: Effect.fn(function* ({ news }) {
273 let route = yield* findRoute(news);
274 if (route) yield* requireManualRoute(route);
275 if (
276 route &&
277 (route.Status?.Code === "deleting" ||
278 (route.Description ?? "") !== (news.description ?? ""))
279 ) {
280 yield* removeRoute(news);
281 route = undefined;
282 }
283 if (!route) {
284 // Do not replay a completed create token after out-of-band deletion.
285 const clientToken = yield* Effect.sync(() => crypto.randomUUID());
286 yield* ec2
287 .createClientVpnRoute({
288 ClientVpnEndpointId: news.clientVpnEndpointId,
289 DestinationCidrBlock: canonicalCidr(news.destinationCidrBlock),
290 TargetVpcSubnetId: news.targetVpcSubnetId,
291 Description: news.description,
292 ClientToken: clientToken,
293 })
294 .pipe(
295 Effect.catchTag(
296 "InvalidClientVpnDuplicateRoute",
297 () => Effect.void,
298 ),
299 (effect) =>
300 retryClientVpn(
301 effect,
302 (error) => error._tag === "IncorrectState",
303 ),
304 );
305 }
306 const active = yield* waitForRoute(news, false);
307 return toAttributes(news.clientVpnEndpointId, active!);
308 }),
309 delete: Effect.fn(function* ({ output }) {
310 yield* removeRoute(output);
311 }),
312 };
313 }),
314 );