| 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. |
| 9 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 10 | import * as Clock from "effect/Clock"; |
| 11 | import * as Config from "effect/Config"; |
| 12 | import * as Console from "effect/Console"; |
| 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Redacted from "effect/Redacted"; |
| 15 | import * as Schema from "effect/Schema"; |
| 16 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 17 | import * as HttpClient from "effect/http/HttpClient"; |
| 18 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 19 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 20 | |
| 21 | const PREFIX = "t3coderelay-managedendpoint-"; |
| 22 | const PAGE_SIZE = 1_000; |
| 23 | const 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. |
| 27 | const 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 | }); |
| 33 | type Tunnel = typeof Tunnel.Type; |
| 34 | const TunnelPage = Schema.Struct({ result: Schema.Array(Tunnel) }); |
| 35 | |
| 36 | // "t3coderelay-managedendpoint-<stage>-<16 hex>"; stages may contain hyphens. |
| 37 | const 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. |
| 42 | const 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 | |
| 54 | const 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 | |
| 97 | NodeRuntime.runMain(main); |