1import * as crypto from "node:crypto";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import { HttpServerRequest } from "effect/http/HttpServerRequest";
5import * as HttpServerResponse from "effect/http/HttpServerResponse";
6import type { HttpEffect } from "../Http.ts";
7import { RPC_PATH_PREFIX, serveRpc } from "../Rpc.ts";
8
9/** Machine env var holding the Service's caller token. */
10export const RPC_TOKEN_ENV = "ALCHEMY_FLY_RPC_TOKEN";
11/** Machine env var holding the organization callers must belong to. */
12export 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 */
17export const BINDING_PORT_ENV = "ALCHEMY_FLY_BINDING_PORT";
18/** Request header carrying the caller token. */
19export const RPC_TOKEN_HEADER = "x-alchemy-rpc-token";
20/** Published port Alchemy adds when a Service has no plain-HTTP port. */
21export const DEFAULT_BINDING_PORT = 7780;
22
23/** Fly's signed origin header on Flycast requests, and its public key. */
24const FLY_SRC_HEADER = "fly-src";
25const FLY_SRC_SIGNATURE_HEADER = "fly-src-signature";
26const FLY_SRC_PUBLIC_KEY = "/.fly/fly-src.pub";
27/** DER prefix that wraps a raw 32-byte ed25519 key as SubjectPublicKeyInfo. */
28const ED25519_SPKI_PREFIX = "302a300506032b6570032100";
29/** Oldest accepted `Fly-Src` timestamp, in seconds. */
30const MAX_FLY_SRC_AGE_SECONDS = 300;
31
32const header = (
33 headers: Record<string, string | undefined>,
34 name: string,
35): string => headers[name] ?? "";
36
37const 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
46let publicKey: crypto.KeyObject | undefined;
47
48/** Load Fly's `Fly-Src` verification key once per instance. */
49const 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 */
73export 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
104const 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 */
113export 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. */
147export 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 );