| 1 | import * as crypto from "node:crypto"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 5 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 6 | import type { HttpEffect } from "../Http.ts"; |
| 7 | import { RPC_PATH_PREFIX, serveRpc } from "../Rpc.ts"; |
| 8 | |
| 9 | /** Machine env var holding the Service's caller token. */ |
| 10 | export const RPC_TOKEN_ENV = "ALCHEMY_FLY_RPC_TOKEN"; |
| 11 | /** Machine env var holding the organization callers must belong to. */ |
| 12 | export const RPC_ORG_ENV = "ALCHEMY_FLY_ORG"; |
| 13 | /** |
| 14 | * Machine env var holding the published port Alchemy added for bindings. |
| 15 | * Requests on it must come from Fly's private network. |
| 16 | */ |
| 17 | export const BINDING_PORT_ENV = "ALCHEMY_FLY_BINDING_PORT"; |
| 18 | /** Request header carrying the caller token. */ |
| 19 | export const RPC_TOKEN_HEADER = "x-alchemy-rpc-token"; |
| 20 | /** Published port Alchemy adds when a Service has no plain-HTTP port. */ |
| 21 | export const DEFAULT_BINDING_PORT = 7780; |
| 22 | |
| 23 | /** Fly's signed origin header on Flycast requests, and its public key. */ |
| 24 | const FLY_SRC_HEADER = "fly-src"; |
| 25 | const FLY_SRC_SIGNATURE_HEADER = "fly-src-signature"; |
| 26 | const FLY_SRC_PUBLIC_KEY = "/.fly/fly-src.pub"; |
| 27 | /** DER prefix that wraps a raw 32-byte ed25519 key as SubjectPublicKeyInfo. */ |
| 28 | const ED25519_SPKI_PREFIX = "302a300506032b6570032100"; |
| 29 | /** Oldest accepted `Fly-Src` timestamp, in seconds. */ |
| 30 | const MAX_FLY_SRC_AGE_SECONDS = 300; |
| 31 | |
| 32 | const header = ( |
| 33 | headers: Record<string, string | undefined>, |
| 34 | name: string, |
| 35 | ): string => headers[name] ?? ""; |
| 36 | |
| 37 | const tokensEqual = (left: string, right: string): boolean => { |
| 38 | if (left.length !== right.length) return false; |
| 39 | let mismatch = 0; |
| 40 | for (let i = 0; i < left.length; i++) { |
| 41 | mismatch |= left.charCodeAt(i) ^ right.charCodeAt(i); |
| 42 | } |
| 43 | return mismatch === 0; |
| 44 | }; |
| 45 | |
| 46 | let publicKey: crypto.KeyObject | undefined; |
| 47 | |
| 48 | /** Load Fly's `Fly-Src` verification key once per instance. */ |
| 49 | const loadPublicKey = Effect.gen(function* () { |
| 50 | if (publicKey !== undefined) return publicKey; |
| 51 | const fs = yield* FileSystem.FileSystem; |
| 52 | const hex = yield* fs.readFileString(FLY_SRC_PUBLIC_KEY).pipe( |
| 53 | Effect.map((text) => text.trim()), |
| 54 | Effect.orElseSucceed(() => ""), |
| 55 | ); |
| 56 | if (hex.length === 0) return undefined; |
| 57 | const key = yield* Effect.try(() => |
| 58 | crypto.createPublicKey({ |
| 59 | key: Buffer.from(`${ED25519_SPKI_PREFIX}${hex}`, "hex"), |
| 60 | format: "der", |
| 61 | type: "spki", |
| 62 | }), |
| 63 | ).pipe(Effect.orElseSucceed(() => undefined)); |
| 64 | publicKey = key; |
| 65 | return key; |
| 66 | }); |
| 67 | |
| 68 | /** |
| 69 | * Whether the request carries a valid `Fly-Src` signature from Fly's proxy |
| 70 | * for a caller in `org`. Fly adds the header only to requests that arrive |
| 71 | * over its private network, so a public request can never pass. |
| 72 | */ |
| 73 | export const verifyFlySrc = ( |
| 74 | headers: Record<string, string | undefined>, |
| 75 | org: string, |
| 76 | ) => |
| 77 | Effect.gen(function* () { |
| 78 | const source = header(headers, FLY_SRC_HEADER); |
| 79 | const signature = header(headers, FLY_SRC_SIGNATURE_HEADER); |
| 80 | if (source.length === 0 || signature.length === 0 || org.length === 0) |
| 81 | return false; |
| 82 | const key = yield* loadPublicKey; |
| 83 | if (key === undefined) return false; |
| 84 | const valid = yield* Effect.try(() => |
| 85 | crypto.verify( |
| 86 | null, |
| 87 | Buffer.from(source), |
| 88 | key, |
| 89 | Buffer.from(signature, "base64"), |
| 90 | ), |
| 91 | ).pipe(Effect.orElseSucceed(() => false)); |
| 92 | if (!valid) return false; |
| 93 | const fields = Object.fromEntries( |
| 94 | source.split(";").map((pair) => { |
| 95 | const at = pair.indexOf("="); |
| 96 | return [pair.slice(0, at), pair.slice(at + 1)]; |
| 97 | }), |
| 98 | ); |
| 99 | const now = yield* Effect.sync(() => Date.now() / 1000); |
| 100 | const age = now - Number(fields.ts); |
| 101 | return fields.org === org && age >= -60 && age <= MAX_FLY_SRC_AGE_SECONDS; |
| 102 | }); |
| 103 | |
| 104 | const unauthorized = HttpServerResponse.text("Unauthorized", { status: 401 }); |
| 105 | |
| 106 | /** |
| 107 | * Serve a Service's RPC methods on `/__rpc__/*` and everything else with |
| 108 | * `fallback`. RPC calls must carry the Service's caller token and a valid |
| 109 | * `Fly-Src` signature from the Service's organization. Every request on the |
| 110 | * port Alchemy added for bindings must carry the `Fly-Src` signature, so |
| 111 | * that port never answers the public internet. |
| 112 | */ |
| 113 | export const serveFlyRpc = <Req = never>( |
| 114 | shape: Record<string, unknown>, |
| 115 | fallback: HttpEffect<Req>, |
| 116 | ): HttpEffect<Req | FileSystem.FileSystem> => |
| 117 | Effect.gen(function* () { |
| 118 | const request = yield* HttpServerRequest; |
| 119 | const headers = request.headers as Record<string, string | undefined>; |
| 120 | const isRpc = request.url.includes(RPC_PATH_PREFIX); |
| 121 | const env = yield* Effect.sync(() => ({ |
| 122 | bindingPort: process.env[BINDING_PORT_ENV] ?? "", |
| 123 | org: process.env[RPC_ORG_ENV] ?? "", |
| 124 | token: process.env[RPC_TOKEN_ENV] ?? "", |
| 125 | })); |
| 126 | const bindingPort = env.bindingPort; |
| 127 | const onBindingPort = |
| 128 | bindingPort.length > 0 && |
| 129 | header(headers, "fly-forwarded-port") === bindingPort; |
| 130 | if (!isRpc && !onBindingPort) return yield* fallback; |
| 131 | if (!(yield* verifyFlySrc(headers, env.org))) return unauthorized; |
| 132 | if (isRpc) { |
| 133 | const expected = env.token; |
| 134 | const provided = header(headers, RPC_TOKEN_HEADER); |
| 135 | if ( |
| 136 | expected.length === 0 || |
| 137 | provided.length === 0 || |
| 138 | !tokensEqual(provided, expected) |
| 139 | ) { |
| 140 | return unauthorized; |
| 141 | } |
| 142 | } |
| 143 | return yield* serveRpc(shape, fallback); |
| 144 | }); |
| 145 | |
| 146 | /** Methods on an impl shape that are served as RPC. */ |
| 147 | export const rpcMethodsOf = (shape: Record<string, unknown> | undefined) => |
| 148 | Object.fromEntries( |
| 149 | Object.entries(shape ?? {}).filter( |
| 150 | ([key, value]) => |
| 151 | key !== "fetch" && key !== "run" && typeof value === "function", |
| 152 | ), |
| 153 | ); |