| 1 | import { Subnet, Vpc } from "@/AWS/EC2"; |
| 2 | import * as Lambda from "@/AWS/Lambda"; |
| 3 | import * as NetworkFirewall from "@/AWS/NetworkFirewall"; |
| 4 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Result from "effect/Result"; |
| 8 | import * as Schedule from "effect/Schedule"; |
| 9 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 10 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 11 | import path from "pathe"; |
| 12 | |
| 13 | const main = path.resolve(import.meta.dirname, "firewall-handler.ts"); |
| 14 | |
| 15 | export class NetworkFirewallFirewallBindingsFunction extends Lambda.Function<Lambda.Function>()( |
| 16 | "NetworkFirewallFirewallBindingsFunction", |
| 17 | ) {} |
| 18 | |
| 19 | /** |
| 20 | * Firewall-scoped bindings fixture. A firewall takes ~5-10 minutes to |
| 21 | * provision its endpoints, so this fixture is only deployed by the |
| 22 | * `AWS_TEST_NETWORKFIREWALL=1`-gated tests in `FirewallBindings.test.ts`. |
| 23 | */ |
| 24 | export default NetworkFirewallFirewallBindingsFunction.make( |
| 25 | { |
| 26 | main, |
| 27 | functionUrl: true, |
| 28 | timeout: Duration.seconds(120), |
| 29 | }, |
| 30 | Effect.gen(function* () { |
| 31 | const vpc = yield* Vpc("BindingsVpc", { |
| 32 | cidrBlock: "10.78.0.0/16", |
| 33 | tags: { fixture: "nfw-bindings" }, |
| 34 | }); |
| 35 | const subnet = yield* Subnet("BindingsFirewallSubnet", { |
| 36 | vpcId: vpc.vpcId, |
| 37 | cidrBlock: "10.78.1.0/24", |
| 38 | tags: { fixture: "nfw-bindings" }, |
| 39 | }); |
| 40 | const policy = yield* NetworkFirewall.FirewallPolicy("BindingsFwPolicy", { |
| 41 | firewallPolicy: { |
| 42 | StatelessDefaultActions: ["aws:pass"], |
| 43 | StatelessFragmentDefaultActions: ["aws:pass"], |
| 44 | }, |
| 45 | }); |
| 46 | const firewall = yield* NetworkFirewall.Firewall("BindingsFirewall", { |
| 47 | firewallPolicyArn: policy.firewallPolicyArn, |
| 48 | vpcId: vpc.vpcId, |
| 49 | subnetMappings: [{ SubnetId: subnet.subnetId }], |
| 50 | tags: { fixture: "nfw-bindings" }, |
| 51 | }); |
| 52 | |
| 53 | const bound = { |
| 54 | describeFirewall: yield* NetworkFirewall.DescribeFirewall(firewall), |
| 55 | startFlowCapture: yield* NetworkFirewall.StartFlowCapture(firewall), |
| 56 | startFlowFlush: yield* NetworkFirewall.StartFlowFlush(firewall), |
| 57 | describeFlowOperation: |
| 58 | yield* NetworkFirewall.DescribeFlowOperation(firewall), |
| 59 | listFlowOperations: yield* NetworkFirewall.ListFlowOperations(firewall), |
| 60 | listFlowOperationResults: |
| 61 | yield* NetworkFirewall.ListFlowOperationResults(firewall), |
| 62 | startAnalysisReport: yield* NetworkFirewall.StartAnalysisReport(firewall), |
| 63 | listAnalysisReports: yield* NetworkFirewall.ListAnalysisReports(firewall), |
| 64 | getAnalysisReportResults: |
| 65 | yield* NetworkFirewall.GetAnalysisReportResults(firewall), |
| 66 | }; |
| 67 | |
| 68 | // A narrow filter so the capture/flush stays tiny. |
| 69 | const flowFilters = [ |
| 70 | { SourceAddress: { AddressDefinition: "10.78.1.10/32" } }, |
| 71 | ]; |
| 72 | |
| 73 | return { |
| 74 | fetch: Effect.gen(function* () { |
| 75 | const request = yield* HttpServerRequest; |
| 76 | const url = new URL(request.originalUrl); |
| 77 | const pathname = url.pathname; |
| 78 | |
| 79 | if (request.method === "GET" && pathname === "/bindings") { |
| 80 | return yield* HttpServerResponse.json({ |
| 81 | bound: Object.keys(bound), |
| 82 | }); |
| 83 | } |
| 84 | |
| 85 | // Firewall-scoped describe — the firewall ARN is injected. |
| 86 | if (request.method === "GET" && pathname === "/firewall") { |
| 87 | const response = yield* bound.describeFirewall(); |
| 88 | return yield* HttpServerResponse.json({ |
| 89 | status: response.FirewallStatus?.Status, |
| 90 | endpointCount: Object.keys( |
| 91 | response.FirewallStatus?.SyncStates ?? {}, |
| 92 | ).length, |
| 93 | }); |
| 94 | } |
| 95 | |
| 96 | // Full flow-operation loop: start a capture, poll (bounded) until |
| 97 | // terminal, then read it back through the list interfaces. Each |
| 98 | // step reports its typed failure so a single grant gap is |
| 99 | // diagnosable from the JSON instead of a generic 500. |
| 100 | if (request.method === "GET" && pathname === "/flow") { |
| 101 | const started = yield* Effect.result( |
| 102 | bound.startFlowCapture({ FlowFilters: flowFilters }), |
| 103 | ); |
| 104 | if (Result.isFailure(started)) { |
| 105 | return yield* HttpServerResponse.json({ |
| 106 | step: "startFlowCapture", |
| 107 | tag: started.failure._tag, |
| 108 | error: String(started.failure), |
| 109 | }); |
| 110 | } |
| 111 | const FlowOperationId = started.success.FlowOperationId!; |
| 112 | const polled = yield* Effect.result( |
| 113 | bound.describeFlowOperation({ FlowOperationId }).pipe( |
| 114 | Effect.repeat({ |
| 115 | schedule: Schedule.spaced("2 seconds"), |
| 116 | until: (r): boolean => r.FlowOperationStatus !== "IN_PROGRESS", |
| 117 | times: 20, |
| 118 | }), |
| 119 | ), |
| 120 | ); |
| 121 | if (Result.isFailure(polled)) { |
| 122 | return yield* HttpServerResponse.json({ |
| 123 | step: "describeFlowOperation", |
| 124 | tag: polled.failure._tag, |
| 125 | error: String(polled.failure), |
| 126 | }); |
| 127 | } |
| 128 | const listed = yield* Effect.result( |
| 129 | bound.listFlowOperations({ FlowOperationType: "FLOW_CAPTURE" }), |
| 130 | ); |
| 131 | if (Result.isFailure(listed)) { |
| 132 | return yield* HttpServerResponse.json({ |
| 133 | step: "listFlowOperations", |
| 134 | tag: listed.failure._tag, |
| 135 | error: String(listed.failure), |
| 136 | }); |
| 137 | } |
| 138 | const results = yield* Effect.result( |
| 139 | bound.listFlowOperationResults({ FlowOperationId }), |
| 140 | ); |
| 141 | if (Result.isFailure(results)) { |
| 142 | return yield* HttpServerResponse.json({ |
| 143 | step: "listFlowOperationResults", |
| 144 | tag: results.failure._tag, |
| 145 | error: String(results.failure), |
| 146 | }); |
| 147 | } |
| 148 | return yield* HttpServerResponse.json({ |
| 149 | step: "ok", |
| 150 | flowOperationId: FlowOperationId, |
| 151 | status: polled.success.FlowOperationStatus, |
| 152 | operations: (listed.success.FlowOperations ?? []).length, |
| 153 | flows: (results.success.Flows ?? []).length, |
| 154 | }); |
| 155 | } |
| 156 | |
| 157 | // Flush — success or any typed rejection proves the grant. |
| 158 | if (request.method === "GET" && pathname === "/flush") { |
| 159 | const tag = yield* bound |
| 160 | .startFlowFlush({ FlowFilters: flowFilters }) |
| 161 | .pipe( |
| 162 | Effect.map(() => "ok"), |
| 163 | Effect.catch((e) => Effect.succeed(e._tag)), |
| 164 | ); |
| 165 | return yield* HttpServerResponse.json({ tag }); |
| 166 | } |
| 167 | |
| 168 | // Analysis-report interface. The fixture firewall has no analysis |
| 169 | // types enabled, so StartAnalysisReport and a bogus |
| 170 | // GetAnalysisReportResults round-trip to typed rejections (an IAM |
| 171 | // gap would surface AccessDeniedException instead); the list is a |
| 172 | // real (empty) read. |
| 173 | if (request.method === "GET" && pathname === "/analysis") { |
| 174 | const startTag = yield* bound |
| 175 | .startAnalysisReport({ AnalysisType: "TLS_SNI" }) |
| 176 | .pipe( |
| 177 | Effect.map(() => "ok"), |
| 178 | Effect.catch((e) => Effect.succeed(e._tag)), |
| 179 | ); |
| 180 | const listed = yield* Effect.result(bound.listAnalysisReports()); |
| 181 | if (Result.isFailure(listed)) { |
| 182 | return yield* HttpServerResponse.json({ |
| 183 | step: "listAnalysisReports", |
| 184 | tag: listed.failure._tag, |
| 185 | error: String(listed.failure), |
| 186 | }); |
| 187 | } |
| 188 | const getTag = yield* bound |
| 189 | .getAnalysisReportResults({ |
| 190 | AnalysisReportId: "alchemy-nonexistent-analysis-report-id", |
| 191 | }) |
| 192 | .pipe( |
| 193 | Effect.map(() => "ok"), |
| 194 | Effect.catch((e) => Effect.succeed(e._tag)), |
| 195 | ); |
| 196 | return yield* HttpServerResponse.json({ |
| 197 | step: "ok", |
| 198 | startTag, |
| 199 | reports: (listed.success.AnalysisReports ?? []).length, |
| 200 | getTag, |
| 201 | }); |
| 202 | } |
| 203 | |
| 204 | return yield* HttpServerResponse.json( |
| 205 | { error: "Not found", method: request.method, pathname }, |
| 206 | { status: 404 }, |
| 207 | ); |
| 208 | }).pipe(Effect.orDie), |
| 209 | }; |
| 210 | }).pipe( |
| 211 | Effect.provide( |
| 212 | Layer.mergeAll( |
| 213 | NetworkFirewall.DescribeFirewallHttp, |
| 214 | NetworkFirewall.StartFlowCaptureHttp, |
| 215 | NetworkFirewall.StartFlowFlushHttp, |
| 216 | NetworkFirewall.DescribeFlowOperationHttp, |
| 217 | NetworkFirewall.ListFlowOperationsHttp, |
| 218 | NetworkFirewall.ListFlowOperationResultsHttp, |
| 219 | NetworkFirewall.StartAnalysisReportHttp, |
| 220 | NetworkFirewall.ListAnalysisReportsHttp, |
| 221 | NetworkFirewall.GetAnalysisReportResultsHttp, |
| 222 | ), |
| 223 | ), |
| 224 | ), |
| 225 | ); |