1import * as Hetzner from "@distilled.cloud/hetzner";
2import type {
3 CreateFirewallRequestApplyToItem,
4 CreateFirewallRequestRulesItem,
5 GetFirewallResponseFirewall,
6 GetFirewallResponseFirewallAppliedToItem,
7 GetFirewallResponseFirewallRulesItem,
8 ListFirewallsResponseFirewallsItem,
9} from "@distilled.cloud/hetzner/firewalls";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import * as Stream from "effect/Stream";
13import { Unowned } from "../AdoptPolicy.ts";
14import { deepEqual, isResolved } from "../Diff.ts";
15import { createPhysicalName } from "../PhysicalName.ts";
16import * as Provider from "../Provider.ts";
17import { Resource } from "../Resource.ts";
18import { recordsEqual } from "../Util/equal.ts";
19import { waitForActions } from "./actions.ts";
20import {
21 alchemyStackSelector,
22 createInternalLabels,
23 diffLabels,
24 hasAlchemyLabels,
25 labelSelector,
26 stripInternalLabels,
27 toLabels,
28} from "./Labels.ts";
29import type { Providers } from "./Providers.ts";
30
31/**
32 * Resource-valued prop. The engine resolves `Effect`s (including other
33 * Resources) before lifecycle operations see the value.
34 */
35type Ref<T> = T | Effect.Effect<T, never, Providers>;
36
37/**
38 * Server identity used by `applyTo`. A `Hetzner.Server` resource
39 * satisfies this via `serverId`.
40 */
41type Server = {
42 readonly serverId: number;
43};
44
45export type FirewallDirection = "in" | "out";
46export type FirewallProtocol = "tcp" | "udp" | "icmp" | "esp" | "gre";
47
48export interface FirewallRule {
49 /**
50 * Description of the rule.
51 */
52 description?: string;
53 /**
54 * Traffic direction. Incoming rules use `sourceIps`; outgoing rules
55 * use `destinationIps`.
56 */
57 direction: FirewallDirection;
58 /**
59 * Network protocol this rule applies to.
60 */
61 protocol: FirewallProtocol;
62 /**
63 * Port or port range (`22` or `1024-5000`). Only valid for `tcp` and
64 * `udp`.
65 */
66 port?: string;
67 /**
68 * Permitted source CIDRs for incoming traffic. Use `0.0.0.0/0` and
69 * `::/0` to allow any address.
70 */
71 sourceIps?: string[];
72 /**
73 * Permitted destination CIDRs for outgoing traffic. Use `0.0.0.0/0`
74 * and `::/0` to allow any address.
75 */
76 destinationIps?: string[];
77}
78
79export interface FirewallProps {
80 /**
81 * Name of the firewall. Must be unique per Hetzner project. If
82 * omitted, a unique name is generated from `${stack}-${id}-${stage}`.
83 */
84 name?: string;
85 /**
86 * Firewall rules. Limited to 50 entries per firewall. An empty list
87 * (the default) drops all inbound traffic and accepts all outbound
88 * traffic.
89 */
90 rules?: FirewallRule[];
91 /**
92 * Servers this firewall is applied to. This wave's tests leave
93 * `applyTo` empty; the type still accepts `Server` resources.
94 */
95 applyTo?: Array<Ref<Server>>;
96 /**
97 * User-defined labels. Alchemy ownership labels (`alchemy.stack`,
98 * `alchemy.stage`, `alchemy.id`) are always merged in.
99 */
100 labels?: Record<string, string>;
101}
102
103export type FirewallAppliedTo = {
104 type: "server";
105 serverId: number;
106};
107
108export interface Firewall extends Resource<
109 "Hetzner.Firewall",
110 FirewallProps,
111 {
112 /**
113 * Numeric Hetzner firewall id.
114 */
115 id: number;
116 /**
117 * Name of the firewall.
118 */
119 name: string;
120 /**
121 * RFC3339 timestamp when the firewall was created.
122 */
123 created: string;
124 /**
125 * Current rules, in the same camelCase shape as {@link FirewallRule}.
126 */
127 rules: FirewallRule[];
128 /**
129 * Server ids the firewall is currently applied to (direct
130 * `type=server` attachments only).
131 */
132 appliedTo: FirewallAppliedTo[];
133 /**
134 * User-facing labels (Alchemy ownership labels stripped).
135 */
136 labels: Record<string, string>;
137 },
138 never,
139 Providers
140> {}
141
142/**
143 * A Hetzner Cloud firewall — a named set of inbound/outbound rules that
144 * can be applied to one or more Servers.
145 *
146 * Name, rules, labels, and `applyTo` are all mutable. Changing the name
147 * updates the existing firewall in place (it is unique per project).
148 * @see https://docs.hetzner.cloud/reference/cloud#firewalls
149 *
150 * ### Creating a Firewall
151 * **Example:** Basic firewall
152 * ```typescript
153 * const web = yield* Hetzner.Firewall("web", {
154 * rules: [
155 * {
156 * direction: "in",
157 * protocol: "tcp",
158 * port: "22",
159 * sourceIps: ["0.0.0.0/0", "::/0"],
160 * },
161 * ],
162 * });
163 * ```
164 *
165 * **Example:** Firewall applied to a Server
166 * ```typescript
167 * const server = yield* Hetzner.Server("app", {
168 * image: "ubuntu-24.04",
169 * serverType: "cx22",
170 * location: "nbg1",
171 * });
172 * const web = yield* Hetzner.Firewall("web", {
173 * applyTo: [server],
174 * rules: [
175 * {
176 * direction: "in",
177 * protocol: "tcp",
178 * port: "443",
179 * sourceIps: ["0.0.0.0/0", "::/0"],
180 * },
181 * ],
182 * });
183 * ```
184 *
185 * ### Updating rules
186 * **Example:** Replace the rule set
187 * ```typescript
188 * const web = yield* Hetzner.Firewall("web", {
189 * rules: [
190 * {
191 * direction: "in",
192 * protocol: "tcp",
193 * port: "80",
194 * sourceIps: ["0.0.0.0/0", "::/0"],
195 * },
196 * {
197 * direction: "in",
198 * protocol: "tcp",
199 * port: "443",
200 * sourceIps: ["0.0.0.0/0", "::/0"],
201 * },
202 * ],
203 * });
204 * ```
205 *
206 * @resource
207 * @product Firewall
208 */
209export const Firewall = Resource<Firewall>("Hetzner.Firewall");
210
211export class FirewallNotCreated extends Data.TaggedError(
212 "Hetzner.FirewallNotCreated",
213)<{
214 name: string;
215}> {}
216
217type FirewallAttributes = Firewall["Attributes"];
218type ObservedFirewall =
219 | GetFirewallResponseFirewall
220 | ListFirewallsResponseFirewallsItem;
221
222const NAME_MAX_LENGTH = 128;
223
224const createFirewallName = (id: string, name: string | undefined) =>
225 Effect.gen(function* () {
226 if (name !== undefined) return name;
227 return yield* createPhysicalName({ id, maxLength: NAME_MAX_LENGTH });
228 });
229
230const compactLabels = (
231 labels: Record<string, string | undefined> | null | undefined,
232): Record<string, string> =>
233 Object.fromEntries(
234 Object.entries(labels ?? {}).filter(
235 (entry): entry is [string, string] => entry[1] !== undefined,
236 ),
237 );
238
239const desiredLabels = Effect.fn(function* (
240 id: string,
241 user: Record<string, string> | undefined,
242) {
243 const internal = yield* createInternalLabels(id);
244 return { ...toLabels(user), ...internal };
245});
246
247const sortIps = (ips: ReadonlyArray<string> | undefined): string[] =>
248 [...(ips ?? [])].map((ip) => ip.toLowerCase()).sort();
249
250const normalizeRule = (rule: {
251 description?: string | null;
252 direction: string;
253 protocol: string;
254 port?: string | null;
255 sourceIps?: ReadonlyArray<string>;
256 destinationIps?: ReadonlyArray<string>;
257}): FirewallRule => {
258 const description = rule.description ?? undefined;
259 const port = rule.port ?? undefined;
260 return {
261 direction: rule.direction as FirewallDirection,
262 protocol: rule.protocol as FirewallProtocol,
263 ...(description !== undefined ? { description } : {}),
264 ...(port !== undefined && port.length > 0 ? { port } : {}),
265 sourceIps: sortIps(rule.sourceIps),
266 destinationIps: sortIps(rule.destinationIps),
267 };
268};
269
270const fromObservedRule = (
271 rule: GetFirewallResponseFirewallRulesItem,
272): FirewallRule =>
273 normalizeRule({
274 description: rule.description,
275 direction: rule.direction,
276 protocol: rule.protocol,
277 port: rule.port,
278 sourceIps: rule.source_ips,
279 destinationIps: rule.destination_ips,
280 });
281
282const toWireRule = (rule: FirewallRule): CreateFirewallRequestRulesItem => {
283 const description = rule.description;
284 const port =
285 (rule.protocol === "tcp" || rule.protocol === "udp") && rule.port
286 ? rule.port
287 : undefined;
288 return {
289 direction: rule.direction,
290 protocol: rule.protocol,
291 ...(description !== undefined ? { description } : {}),
292 ...(port !== undefined ? { port } : {}),
293 ...(rule.sourceIps !== undefined
294 ? { source_ips: [...rule.sourceIps] }
295 : {}),
296 ...(rule.destinationIps !== undefined
297 ? { destination_ips: [...rule.destinationIps] }
298 : {}),
299 };
300};
301
302const desiredRules = (rules: FirewallRule[] | undefined): FirewallRule[] =>
303 (rules ?? []).map((rule) =>
304 normalizeRule({
305 description: rule.description,
306 direction: rule.direction,
307 protocol: rule.protocol,
308 port: rule.port,
309 sourceIps: rule.sourceIps,
310 destinationIps: rule.destinationIps,
311 }),
312 );
313
314const rulesEqual = (a: FirewallRule[], b: FirewallRule[]): boolean =>
315 deepEqual(a, b, { stripNullish: true });
316
317const serverIdOf = (value: unknown): number | undefined => {
318 if (value === null || typeof value !== "object") return undefined;
319 const rec = value as { serverId?: unknown; id?: unknown };
320 if (typeof rec.serverId === "number") return rec.serverId;
321 if (typeof rec.id === "number") return rec.id;
322 return undefined;
323};
324
325const desiredServerIds = (
326 applyTo: FirewallProps["applyTo"] | undefined,
327): number[] => {
328 const ids = new Set<number>();
329 for (const item of applyTo ?? []) {
330 const id = serverIdOf(item);
331 if (id !== undefined) ids.add(id);
332 }
333 return [...ids].sort((a, b) => a - b);
334};
335
336const observedServerIds = (
337 appliedTo: ReadonlyArray<GetFirewallResponseFirewallAppliedToItem>,
338): number[] => {
339 const ids = new Set<number>();
340 for (const item of appliedTo) {
341 if (item.type === "server" && item.server?.id !== undefined) {
342 ids.add(item.server.id);
343 }
344 }
345 return [...ids].sort((a, b) => a - b);
346};
347
348const toAppliedTo = (
349 appliedTo: ReadonlyArray<GetFirewallResponseFirewallAppliedToItem>,
350): FirewallAppliedTo[] =>
351 observedServerIds(appliedTo).map((serverId) => ({
352 type: "server" as const,
353 serverId,
354 }));
355
356const toServerApplyItems = (
357 ids: ReadonlyArray<number>,
358): CreateFirewallRequestApplyToItem[] =>
359 ids.map((id) => ({ type: "server" as const, server: { id } }));
360
361const detachItems = (
362 appliedTo: ReadonlyArray<GetFirewallResponseFirewallAppliedToItem>,
363): CreateFirewallRequestApplyToItem[] => {
364 const items: CreateFirewallRequestApplyToItem[] = [];
365 for (const item of appliedTo) {
366 if (item.type === "server" && item.server?.id !== undefined) {
367 items.push({ type: "server", server: { id: item.server.id } });
368 } else if (
369 item.type === "label_selector" &&
370 item.label_selector?.selector !== undefined
371 ) {
372 items.push({
373 type: "label_selector",
374 label_selector: { selector: item.label_selector.selector },
375 });
376 }
377 }
378 return items;
379};
380
381const toAttrs = (firewall: ObservedFirewall): FirewallAttributes => ({
382 id: firewall.id,
383 name: firewall.name,
384 created: firewall.created,
385 rules: firewall.rules.map(fromObservedRule),
386 appliedTo: toAppliedTo(firewall.applied_to),
387 labels: stripInternalLabels(compactLabels(firewall.labels)),
388});
389
390const getById = (id: number) =>
391 Hetzner.firewalls.getFirewall({ id }).pipe(
392 Effect.map(({ firewall }) => firewall),
393 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
394 );
395
396const findByName = (name: string) =>
397 Effect.gen(function* () {
398 const { firewalls } = yield* Hetzner.firewalls.listFirewalls({
399 name,
400 per_page: 50,
401 });
402 return firewalls.find((item) => item.name === name);
403 });
404
405const findByLabels = (id: string) =>
406 Effect.gen(function* () {
407 const selector = labelSelector(yield* createInternalLabels(id));
408 const { firewalls } = yield* Hetzner.firewalls.listFirewalls({
409 label_selector: selector,
410 per_page: 50,
411 });
412 return firewalls[0];
413 });
414
415const observe = (input: {
416 id: string;
417 name: string;
418 output: FirewallAttributes | undefined;
419}) =>
420 Effect.gen(function* () {
421 if (input.output?.id !== undefined) {
422 const byId = yield* getById(input.output.id);
423 if (byId !== undefined) return byId;
424 }
425 const byName = yield* findByName(input.name);
426 if (byName !== undefined) return byName;
427 return yield* findByLabels(input.id);
428 });
429
430const waitActions = (actions: ReadonlyArray<{ readonly id: number }>) =>
431 waitForActions(actions.map((action) => action.id));
432
433const ensureFirewall = Effect.fn(function* (input: {
434 name: string;
435 labels: Record<string, string>;
436 rules: FirewallRule[];
437 serverIds: number[];
438}) {
439 const created = yield* Hetzner.firewalls
440 .createFirewall({
441 name: input.name,
442 labels: input.labels,
443 ...(input.rules.length > 0 ? { rules: input.rules.map(toWireRule) } : {}),
444 ...(input.serverIds.length > 0
445 ? { apply_to: toServerApplyItems(input.serverIds) }
446 : {}),
447 })
448 .pipe(
449 Effect.catchTag("Conflict", () =>
450 findByName(input.name).pipe(
451 Effect.flatMap((existing) =>
452 existing !== undefined
453 ? Effect.succeed({ firewall: existing, actions: [] })
454 : Hetzner.firewalls
455 .listFirewalls({
456 name: input.name,
457 per_page: 1,
458 })
459 .pipe(
460 Effect.map(({ firewalls }) => ({
461 firewall: firewalls[0],
462 actions: [],
463 })),
464 ),
465 ),
466 ),
467 ),
468 );
469 yield* waitActions(created.actions ?? []);
470 return created.firewall ?? (yield* findByName(input.name));
471});
472
473const syncNameAndLabels = Effect.fn(function* (input: {
474 firewallId: number;
475 observedName: string;
476 desiredName: string;
477 observedLabels: Record<string, string>;
478 desiredLabels: Record<string, string>;
479}) {
480 const nameChanged = input.observedName !== input.desiredName;
481 const { upsert, removed } = diffLabels(
482 input.observedLabels,
483 input.desiredLabels,
484 );
485 const labelsChanged = upsert.length > 0 || removed.length > 0;
486 if (!nameChanged && !labelsChanged) return;
487 yield* Hetzner.firewalls.updateFirewall({
488 id: input.firewallId,
489 ...(nameChanged ? { name: input.desiredName } : {}),
490 ...(labelsChanged ? { labels: input.desiredLabels } : {}),
491 });
492});
493
494const syncRules = Effect.fn(function* (input: {
495 firewallId: number;
496 observed: FirewallRule[];
497 desired: FirewallRule[];
498}) {
499 if (rulesEqual(input.observed, input.desired)) return;
500 const { actions } = yield* Hetzner.firewallActions.setFirewallRules({
501 id: input.firewallId,
502 rules: input.desired.map(toWireRule),
503 });
504 yield* waitActions(actions);
505});
506
507const syncApplyTo = Effect.fn(function* (input: {
508 firewallId: number;
509 observed: ReadonlyArray<GetFirewallResponseFirewallAppliedToItem>;
510 desiredIds: number[];
511}) {
512 const observedIds = new Set(observedServerIds(input.observed));
513 const desiredIds = new Set(input.desiredIds);
514 const toAdd = [...desiredIds].filter((id) => !observedIds.has(id));
515 const toRemove = [...observedIds].filter((id) => !desiredIds.has(id));
516 if (toAdd.length > 0) {
517 const { actions } = yield* Hetzner.firewallActions.applyFirewallToResources(
518 {
519 id: input.firewallId,
520 apply_to: toServerApplyItems(toAdd),
521 },
522 );
523 yield* waitActions(actions);
524 }
525 if (toRemove.length > 0) {
526 const { actions } =
527 yield* Hetzner.firewallActions.removeFirewallFromResources({
528 id: input.firewallId,
529 remove_from: toServerApplyItems(toRemove),
530 });
531 yield* waitActions(actions);
532 }
533});
534
535const detachAll = Effect.fn(function* (
536 firewallId: number,
537 appliedTo: ReadonlyArray<GetFirewallResponseFirewallAppliedToItem>,
538) {
539 const removeFrom = detachItems(appliedTo);
540 if (removeFrom.length === 0) return;
541 const result = yield* Hetzner.firewallActions
542 .removeFirewallFromResources({
543 id: firewallId,
544 remove_from: removeFrom,
545 })
546 .pipe(
547 Effect.catchTag(["NotFound", "UnprocessableEntity"], () =>
548 Effect.succeed({ actions: [] }),
549 ),
550 );
551 yield* waitActions(result.actions);
552});
553
554export const FirewallProvider = () =>
555 Provider.succeed(Firewall, {
556 stables: ["id", "created"],
557 list: Effect.fn(function* () {
558 const rows = yield* Hetzner.firewalls.listFirewalls
559 .items({ label_selector: alchemyStackSelector, per_page: 50 })
560 .pipe(Stream.runCollect);
561 return Array.from(rows, toAttrs);
562 }),
563 diff: Effect.fn(function* ({ id, olds, news, output }) {
564 if (!isResolved(news)) return undefined;
565 const oldName =
566 output?.name ?? (yield* createFirewallName(id, olds?.name));
567 const newName = news.name ?? oldName;
568 if (oldName !== newName) {
569 return { action: "update" } as const;
570 }
571 if (
572 !rulesEqual(desiredRules(olds?.rules), desiredRules(news.rules)) ||
573 !recordsEqual(olds?.labels ?? {}, news.labels ?? {})
574 ) {
575 return { action: "update" } as const;
576 }
577 const oldIds = desiredServerIds(olds?.applyTo);
578 const newIds = desiredServerIds(news.applyTo);
579 if (!deepEqual(oldIds, newIds)) {
580 return { action: "update" } as const;
581 }
582 if (output !== undefined) {
583 if (!rulesEqual(output.rules, desiredRules(news.rules))) {
584 return { action: "update" } as const;
585 }
586 const outputIds = output.appliedTo.map((item) => item.serverId);
587 if (
588 !deepEqual(
589 [...outputIds].sort((a, b) => a - b),
590 newIds,
591 )
592 ) {
593 return { action: "update" } as const;
594 }
595 }
596 return undefined;
597 }),
598 read: Effect.fn(function* ({ id, olds, output }) {
599 const name =
600 output?.name ??
601 (olds?.name !== undefined
602 ? olds.name
603 : output !== undefined
604 ? undefined
605 : yield* createFirewallName(id, olds?.name));
606 const observed =
607 output?.id !== undefined
608 ? ((yield* getById(output.id)) ??
609 (name !== undefined ? yield* findByName(name) : undefined) ??
610 (yield* findByLabels(id)))
611 : name !== undefined
612 ? ((yield* findByName(name)) ?? (yield* findByLabels(id)))
613 : yield* findByLabels(id);
614 if (observed === undefined) return undefined;
615 const attrs = toAttrs(observed);
616 const ours = yield* hasAlchemyLabels(id, compactLabels(observed.labels));
617 return ours ? attrs : Unowned(attrs);
618 }),
619 reconcile: Effect.fn(function* ({ id, news, output }) {
620 const name =
621 news.name ?? output?.name ?? (yield* createFirewallName(id, news.name));
622 const labels = yield* desiredLabels(id, news.labels);
623 const rules = desiredRules(news.rules);
624 const serverIds = desiredServerIds(news.applyTo);
625
626 // Observe — cached id is a hint; cloud state is authoritative.
627 let current = yield* observe({ id, name, output });
628
629 // Ensure — create if missing. A Conflict is a name-race; look up
630 // the existing firewall and fall through to sync.
631 if (current === undefined) {
632 yield* ensureFirewall({
633 name,
634 labels,
635 rules,
636 serverIds,
637 });
638 }
639 if (current === undefined) {
640 current = yield* observe({ id, name, output });
641 }
642 if (current === undefined) {
643 return yield* new FirewallNotCreated({ name });
644 }
645
646 // Sync each mutable aspect against observed cloud state.
647 yield* syncNameAndLabels({
648 firewallId: current.id,
649 observedName: current.name,
650 desiredName: name,
651 observedLabels: compactLabels(current.labels),
652 desiredLabels: labels,
653 });
654 yield* syncRules({
655 firewallId: current.id,
656 observed: current.rules.map(fromObservedRule),
657 desired: rules,
658 });
659 yield* syncApplyTo({
660 firewallId: current.id,
661 observed: current.applied_to,
662 desiredIds: serverIds,
663 });
664
665 const fresh = (yield* getById(current.id)) ?? current;
666 return toAttrs(fresh);
667 }),
668 delete: Effect.fn(function* ({ output }) {
669 const id = output.id;
670 const observed = yield* getById(id);
671 if (observed !== undefined) {
672 yield* detachAll(id, observed.applied_to);
673 }
674 yield* Hetzner.firewalls.deleteFirewall({ id }).pipe(
675 Effect.catchTag("NotFound", () => Effect.void),
676 Effect.catchTag("UnprocessableEntity", () =>
677 Effect.gen(function* () {
678 const again = yield* getById(id);
679 if (again === undefined) return;
680 yield* detachAll(id, again.applied_to);
681 yield* Hetzner.firewalls
682 .deleteFirewall({ id })
683 .pipe(Effect.catchTag("NotFound", () => Effect.void));
684 }),
685 ),
686 );
687 }),
688 });