IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

cloudflare-api.ts

Open raw
import "server-only";

import { logServerAction } from "@/lib/server/logging/logger";

const API_BASE = "https://api.cloudflare.com/client/v4";

type IngressRule = {
  hostname?: string;
  service: string;
  originRequest?: Record<string, unknown>;
  path?: string;
};

type TunnelConfiguration = {
  ingress: IngressRule[];
  [key: string]: unknown;
};

/**
 * The connector token is base64 of {"a": account tag, "t": tunnel id, "s": secret}.
 * Reading it means the operator supplies one token, not three identifiers.
 */
export function decodeConnectorToken(token: string) {
  try {
    const decoded = JSON.parse(Buffer.from(token.trim(), "base64").toString("utf8")) as {
      a?: unknown;
      t?: unknown;
    };

    if (typeof decoded.a !== "string" || typeof decoded.t !== "string") return null;
    return { accountId: decoded.a, tunnelId: decoded.t };
  } catch {
    return null;
  }
}

/**
 * Cloudflare hands out the whole install command, not the bare token:
 *
 *   sudo cloudflared service install eyJhIjoi...
 *   docker run cloudflare/cloudflared:latest tunnel run --token eyJhIjoi...
 *
 * Pull the token out of whatever was pasted, so copying the command as shown
 * works. Anything with no usable token is returned untouched for validation
 * to reject with a message.
 */
export function normalizeConnectorToken(input: string) {
  const trimmed = input.trim();

  for (const candidate of trimmed.match(/eyJ[A-Za-z0-9+/=_-]+/g) ?? []) {
    if (decodeConnectorToken(candidate)) return candidate;
  }

  return trimmed;
}

async function cloudflareRequest<T>(input: {
  apiToken: string;
  path: string;
  method?: string;
  body?: unknown;
}): Promise<T> {
  const response = await fetch(`${API_BASE}${input.path}`, {
    method: input.method ?? "GET",
    headers: {
      Authorization: `Bearer ${input.apiToken}`,
      "Content-Type": "application/json",
    },
    body: input.body === undefined ? undefined : JSON.stringify(input.body),
  });

  const payload = (await response.json().catch(() => null)) as {
    success?: boolean;
    result?: T;
    errors?: { message?: string }[];
  } | null;

  if (!response.ok || !payload?.success) {
    const detail =
      payload?.errors?.map((error) => error.message).filter(Boolean).join("; ") ||
      `HTTP ${response.status}`;
    throw new Error(`Cloudflare API: ${detail}`);
  }

  return payload.result as T;
}

/**
 * Ask Cloudflare whether the API token is real and active. Pasting the wrong
 * secret into the wrong field is easy, and the failure is otherwise silent:
 * everything saves, and nothing happens until someone reads a log.
 */
export async function verifyApiToken(apiToken: string) {
  const result = await cloudflareRequest<{ status?: string }>({
    apiToken,
    path: "/user/tokens/verify",
  });

  if (result?.status && result.status !== "active") {
    throw new Error(`Cloudflare API token is ${result.status}`);
  }
}

async function resolveZoneId(apiToken: string, domain: string) {
  const zones = await cloudflareRequest<{ id: string; name: string }[]>({
    apiToken,
    path: `/zones?name=${encodeURIComponent(domain)}`,
  });

  const zone = zones?.[0];
  if (!zone) {
    throw new Error(`No Cloudflare zone found for ${domain}`);
  }

  return zone.id;
}

async function getTunnelConfiguration(input: {
  apiToken: string;
  accountId: string;
  tunnelId: string;
}) {
  const result = await cloudflareRequest<{ config?: TunnelConfiguration }>({
    apiToken: input.apiToken,
    path: `/accounts/${input.accountId}/cfd_tunnel/${input.tunnelId}/configurations`,
  });

  return result?.config ?? { ingress: [] };
}

async function putTunnelConfiguration(input: {
  apiToken: string;
  accountId: string;
  tunnelId: string;
  config: TunnelConfiguration;
}) {
  await cloudflareRequest({
    apiToken: input.apiToken,
    path: `/accounts/${input.accountId}/cfd_tunnel/${input.tunnelId}/configurations`,
    method: "PUT",
    body: { config: input.config },
  });
}

/** The catch-all has no hostname and must stay last, so it is rebuilt each time. */
function withCatchAllLast(rules: IngressRule[]) {
  const routed = rules.filter((rule) => Boolean(rule.hostname));
  const catchAll = rules.find((rule) => !rule.hostname) ?? { service: "http_status:404" };
  return [...routed, catchAll];
}

export type CloudflareRouteInput = {
  apiToken: string;
  accountId: string;
  tunnelId: string;
  domain: string;
  hostname: string;
  /** Where the tunnel should send traffic, e.g. http://localhost:8080 */
  service: string;
};

/** Add (or update) the public hostname and the CNAME that points at the tunnel. */
export async function createTunnelRoute(input: CloudflareRouteInput) {
  const config = await getTunnelConfiguration(input);
  const others = (config.ingress ?? []).filter((rule) => rule.hostname !== input.hostname);

  await putTunnelConfiguration({
    ...input,
    config: {
      ...config,
      ingress: withCatchAllLast([
        ...others,
        { hostname: input.hostname, service: input.service },
      ]),
    },
  });

  const zoneId = await resolveZoneId(input.apiToken, input.domain);
  const existing = await cloudflareRequest<{ id: string }[]>({
    apiToken: input.apiToken,
    path: `/zones/${zoneId}/dns_records?name=${encodeURIComponent(input.hostname)}&type=CNAME`,
  });

  const record = {
    type: "CNAME",
    name: input.hostname,
    content: `${input.tunnelId}.cfargotunnel.com`,
    proxied: true,
  };

  if (existing?.[0]) {
    await cloudflareRequest({
      apiToken: input.apiToken,
      path: `/zones/${zoneId}/dns_records/${existing[0].id}`,
      method: "PATCH",
      body: record,
    });
  } else {
    await cloudflareRequest({
      apiToken: input.apiToken,
      path: `/zones/${zoneId}/dns_records`,
      method: "POST",
      body: record,
    });
  }

  logServerAction({
    level: "info",
    layer: "service",
    action: "cloudflare.route.create",
    status: "success",
    message: `Published ${input.hostname}`,
  });
}

/** Remove the ingress rule and the CNAME again. */
export async function deleteTunnelRoute(input: Omit<CloudflareRouteInput, "service">) {
  const config = await getTunnelConfiguration(input);
  const remaining = (config.ingress ?? []).filter((rule) => rule.hostname !== input.hostname);

  await putTunnelConfiguration({
    ...input,
    config: { ...config, ingress: withCatchAllLast(remaining) },
  });

  const zoneId = await resolveZoneId(input.apiToken, input.domain);
  const existing = await cloudflareRequest<{ id: string; content: string }[]>({
    apiToken: input.apiToken,
    path: `/zones/${zoneId}/dns_records?name=${encodeURIComponent(input.hostname)}&type=CNAME`,
  });

  for (const record of existing ?? []) {
    // Only reclaim records that point at this tunnel: the operator may have
    // pointed that name somewhere else by hand.
    if (record.content !== `${input.tunnelId}.cfargotunnel.com`) continue;
    await cloudflareRequest({
      apiToken: input.apiToken,
      path: `/zones/${zoneId}/dns_records/${record.id}`,
      method: "DELETE",
    });
  }

  logServerAction({
    level: "info",
    layer: "service",
    action: "cloudflare.route.delete",
    status: "success",
    message: `Unpublished ${input.hostname}`,
  });
}
bytes
7,277
lines
251
Type
ts
Latest commit
16 October 2026