1// Read-only count of Cloudflare tunnels in the managed-endpoint account,
2// grouped by relay stage, status, and how long they have been idle. The reaper
3// only sees its own stage's prefix, so this shows how much of the backlog
4// belongs to other stages.
5//
6// CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=... node scripts/tunnel-census.ts
7//
8// The token needs Cloudflare Tunnel read access. Nothing is modified.
9import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
10import * as Clock from "effect/Clock";
11import * as Config from "effect/Config";
12import * as Console from "effect/Console";
13import * as Effect from "effect/Effect";
14import * as Redacted from "effect/Redacted";
15import * as Schema from "effect/Schema";
16import * as FetchHttpClient from "effect/http/FetchHttpClient";
17import * as HttpClient from "effect/http/HttpClient";
18import * as HttpClientRequest from "effect/http/HttpClientRequest";
19import * as HttpClientResponse from "effect/http/HttpClientResponse";
20
21const PREFIX = "t3coderelay-managedendpoint-";
22const PAGE_SIZE = 1_000;
23const DAY_MS = 86_400_000;
24
25// Cloudflare documents every field here as optional, so a tunnel missing one
26// must not fail the whole page.
27const Tunnel = Schema.Struct({
28 name: Schema.optional(Schema.NullOr(Schema.String)),
29 status: Schema.optional(Schema.NullOr(Schema.String)),
30 created_at: Schema.optional(Schema.NullOr(Schema.String)),
31 conns_inactive_at: Schema.optional(Schema.NullOr(Schema.String)),
32});
33type Tunnel = typeof Tunnel.Type;
34const TunnelPage = Schema.Struct({ result: Schema.Array(Tunnel) });
35
36// "t3coderelay-managedendpoint-<stage>-<16 hex>"; stages may contain hyphens.
37const stageOf = (name: string | null | undefined) =>
38 name ? name.slice(PREFIX.length).replace(/-[a-f0-9]{16}$/u, "") : "unknown";
39
40// Like the reaper: a down tunnel is aged from when it lost its connector, and a
41// never-connected (inactive) one from when it was created.
42const ageBucket = (tunnel: Tunnel, now: number) => {
43 const since = Date.parse(
44 (tunnel.status === "down" ? tunnel.conns_inactive_at : tunnel.created_at) ?? "",
45 );
46 if (Number.isNaN(since)) return "unknown";
47 const days = (now - since) / DAY_MS;
48 if (days > 90) return ">90d";
49 if (days > 30) return ">30d";
50 if (days > 7) return ">7d";
51 return "<=7d";
52};
53
54const main = Effect.gen(function* () {
55 const accountId = yield* Config.String("CLOUDFLARE_ACCOUNT_ID");
56 const token = yield* Config.Redacted("CLOUDFLARE_API_TOKEN");
57 const client = yield* HttpClient.HttpClient;
58 const now = yield* Clock.currentTimeMillis;
59
60 const listPage = (page: number) =>
61 HttpClientRequest.get(`https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel`, {
62 urlParams: {
63 is_deleted: "false",
64 include_prefix: PREFIX,
65 per_page: String(PAGE_SIZE),
66 page: String(page),
67 },
68 }).pipe(
69 HttpClientRequest.bearerToken(Redacted.value(token)),
70 client.execute,
71 Effect.flatMap(HttpClientResponse.filterStatusOk),
72 Effect.flatMap(HttpClientResponse.schemaBodyJson(TunnelPage)),
73 Effect.map((body) => body.result),
74 );
75
76 const counts = new Map<string, number>();
77 let total = 0;
78 for (let page = 1; ; page += 1) {
79 const tunnels = yield* listPage(page);
80 for (const tunnel of tunnels) {
81 total += 1;
82 const idle =
83 tunnel.status === "down" || tunnel.status === "inactive" ? ageBucket(tunnel, now) : "-";
84 const key = `${stageOf(tunnel.name)}\t${tunnel.status ?? "unknown"}\t${idle}`;
85 counts.set(key, (counts.get(key) ?? 0) + 1);
86 }
87 if (tunnels.length < PAGE_SIZE) break;
88 }
89
90 yield* Console.log("stage\tstatus\tidle\tcount");
91 for (const [key, count] of [...counts].sort(([a], [b]) => a.localeCompare(b))) {
92 yield* Console.log(`${key}\t${count}`);
93 }
94 yield* Console.log(`total\t\t\t${total}`);
95}).pipe(Effect.provide(FetchHttpClient.layer));
96
97NodeRuntime.runMain(main);