标签 Cloudflare Worker 下的文章

本项目是一个 Cloudflare Worker 应用,作为麦当劳 MCP (Model Context Protocol) 服务器的客户端。提供了一个基于网页的用户界面,用于管理 Token、选择工具并可视化执行结果。

mcd-worker

一键部署

Deploy to Cloudflare Workers

  • 网页界面: 采用麦当劳标志性的红黄配色风格。
  • Token 管理:
    • 自动保存 Token 到浏览器本地存储 (LocalStorage)。
    • 支持通过 URL 参数传入 (?token=...)。
    • Token 申请地址: https://open.mcd.cn/mcp
  • 工具选择: 下拉菜单内置了所有可用的麦当劳 MCP 工具。
  • 结果可视化:
    • Markdown 视图: 渲染后的文本,支持表格展示。
    • JSON 视图: 格式化显示原始 JSON 响应数据。
  • API 接口: 提供 /api/execute 接口供程序调用。

方案概述

本方案利用 Cloudflare 的 Zero Trust(原 Cloudflare Access)和 Cloudflare Tunnel(原 Argo Tunnel)功能,实现内网服务的安全访问。

通过配置安全认证和基于 Cloudflare Worker 的动态 IP 白名单,确保只有授权用户能够访问内网资源,同时适配不支持认证的客户端。

前期准备

  • Cloudflare 账号及域名

  • Docker 环境(可选)

  • npm 环境(用于部署 Worker 脚本)


一、配置 Cloudflare Tunnel

1. 创建隧道

  1. 登录 Cloudflare 控制台,选择 Zero Trust

  2. 导航到 网络 > 连接器

  3. 选择 创建隧道 > 选择 Cloudflared > 填入名称 > 保存隧道

2. 启动隧道

网页会提供一段启动命令,复制并在内网服务器上执行。默认显示 Windows,可切换至 Mac、Debian、Red Hat、Docker 等系统。

Docker Compose 方式:

 services: cloudflared:  cloudflare/cloudflared:latest container_name: cloudflared restart: unless-stopped command: tunnel --no-autoupdate run --token <YOUR_TUNNEL_TOKEN> 

3. 配置路由

选择 路由隧道 / 已发布应用程序路由 添加路由,填写子域名和内网服务地址(如 http://localhost:8080)。

注意: 直接填写内网服务地址,不要经过 nginx 等代理。


二、配置 Cloudflare Access 安全认证

1. 创建应用程序

  1. 导航到 Zero Trust > 访问控制 > 应用程序 > 添加应用程序

  2. 输入应用名称

  3. 添加公共主机名(即上一步配置的子域名)

2. 创建访问策略

  1. 选择 创建新策略,输入策略名称

  2. 操作选择 允许

  3. 选择器类型选择 电子邮件

  4. 输入允许访问的用户邮箱(可根据需要选择其他认证方式)

  5. 保存策略

提示: 不支持认证的客户端应用的处理办法将在后续章节介绍。

3. 关联策略

回到应用程序页面,选择现有策略,关联刚创建的策略。

完成配置后,访问该子域名时会被重定向到 Cloudflare 的认证页面,只有通过认证的用户才能访问内网服务。


三、配置动态 IP 白名单(可选)

3.1 适用场景

如果应用程序不支持 Cloudflare Access 认证,或者频繁认证不便,可以通过 Cloudflare Worker 实现动态 IP 白名单功能。

3.2 部署项目

使用项目 cloudflare_dynamic_ip_list 来实现动态 IP 白名单功能。

3.2.1 克隆项目


git clone https://github.com/AinzRimuru/cloudflare_dynamic_ip_list.git

3.2.2 创建 KV Namespace

运行以下命令创建 KV 命名空间,用于维护 IP 的过期状态:


wrangler kv namespace create IP_WHITELIST

记下返回的 ID,填入 wrangler.toml[[kv_namespaces]] 部分的 id 字段。

3.2.3 创建 IP 列表

  1. 打开 Zero Trust > 可重用组件 > 列表 > 创建列表

  2. 创建后记下列表 ID(点开 List 页面,在 URL 中可以看到 ID,形如 ********-****-****-****-************

  3. 将该 ID 填入 wrangler.toml[vars] 部分的 LIST_ID 字段

3.2.4 获取 Account ID

  1. 打开 计算和 AI > Workers 和 Pages

  2. 在右边可以看到 Account Details

  3. 记下 Account ID,填入 wrangler.toml 中的 ACCOUNT_ID 字段

3.2.5 配置允许的域名(可选)

如果配置了新域名,且希望仅允许该域名访问,可以在 wrangler.toml[vars] 部分的 ALLOWED_HOSTS 字段添加域名,多个域名用逗号分隔。

如果不配置该字段,则允许所有域名访问。

3.2.6 部署 Worker 脚本


npx wrangler login

npx wrangler deploy --config config/wrangler.toml

3.3 配置白名单的 Bypass 策略

  1. 在 Cloudflare 控制台,导航到 Zero Trust > 访问控制 > 策略

  2. 创建新策略,名称自定义

  3. 操作选择 BYPASS

  4. 选择器类型选择 IP List,值为刚创建的 List 名称

  5. 保存策略

关联策略: 将策略添加到刚刚创建的应用程序中,排名第 1 位。这样,IP 在白名单中的请求将绕过认证直接访问内网服务。

3.4 IP 白名单的注册鉴权(Token 方式)

3.4.1 创建服务凭据

  1. 选择 Zero Trust > 访问控制 > 服务凭据 > 创建凭据

  2. 输入名称,创建后记下 Client IDClient Secret

3.4.2 创建 Worker 应用程序

  1. 新建应用程序,名称自定义

  2. 公共主机名填写 Worker 脚本的域名(如 your-worker.your-domain.com

3.4.3 创建 SERVICE AUTH 策略

  1. 创建新策略,名称自定义

  2. 操作选择 SERVICE AUTH

  3. 选择器类型选择 Service Token,值为刚创建的服务凭据名称

  4. 保存策略

3.4.4 关联策略

回到 Worker 的应用程序页面,选择现有策略,关联刚创建的策略。

3.5 IP 白名单的注册鉴权(Email 方式)

  1. 创建新策略,名称自定义

  2. 操作选择 Allow

  3. 选择器类型选择 Emails,值为允许注册的邮箱地址(可添加多个)

  4. 保存策略后,回到 Worker 的应用程序页面,关联刚创建的策略

3.6 使用说明

  • 浏览器访问 Worker 脚本的域名,完成认证后,IP 将被添加到白名单中,且在指定时间内有效。

  • 对于不支持认证的客户端应用,可使用自动化任务(如 iOS 的快捷指令等)配置 CF-Access-Client-IdCF-Access-Client-Secret 请求头,实现自动认证。

注意: 自动化任务可能无法指定请求时的 IPv4/IPv6 地址,因此可以尝试重复请求多次以确保 IPv4 和 IPv6 地址均被添加到白名单中。

这些是全部的内容了,但还是希望能够来博客看看。基于 Cloudflare 的内网穿透解决方案(含安全认证及动态 IP 白名单)


📌 转载信息
原作者:
RimuruTempest
转载时间:
2026/1/18 08:51:32

地址 https://kiro.endpoint.cc.cd

使用 Cloudflare Worker 绕过 CORS 限制,实现生成 Device Flow 登录链接
没有做信息存储!!
源代码:

/**
 * worker.js — Kiro Manual Auth (Device Flow) Web Tool
 * Full UI (no cuts): Vercel/shadcn/Inspira-ish + Acrylic hover actions
 *
 * ✅ Flow:
 * Login -> get CID/CS -> get Device Flow link (hover: Copy | Open Link) -> background polling
 * -> success modal pops with Tabs: Viewer / JSON
 *
 * ✅ Viewer tab:
 * - Shows CID / CS + ALL keys from last successful /api/poll JSON
 * - Each row hover shows acrylic blur + "Copy" (same logic as URL hover, no OpenLink)
 *
 * ✅ Poll logic:
 * - No overlapping polls: next poll only after previous finished
 * - If backend returns 400 {"error":"authorization_pending"...}, treat as Pending (yellow) not error
 *
 * ✅ NEW (your request):
 * - Modal content is scrollable (so long tokens won't squeeze/overflow the screen)
 * - Each KV value area is also scrollable (does not cut value; copy still copies full value)
 */

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const path = url.pathname;

    // ===== Config =====
    const OIDC = "https://oidc.us-east-1.amazonaws.com";
    const PORTAL = "https://view.awsapps.com";
    const START_URL = `${PORTAL}/start`;

    // ===== CORS =====
    const corsHeaders = {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type",
    };

    if (request.method === "OPTIONS") {
      return new Response(null, { status: 204, headers: corsHeaders });
    }

    // ===== UI =====
    if (request.method === "GET" && path === "/") {
      return new Response(renderHTML(), {
        headers: {
          "content-type": "text/html; charset=utf-8",
          "cache-control": "no-store",
        },
      });
    }

    // ===== Helpers =====
    const json = (obj, status = 200, extraHeaders = {}) =>
      new Response(JSON.stringify(obj), {
        status,
        headers: {
          "content-type": "application/json; charset=utf-8",
          "cache-control": "no-store",
          ...corsHeaders,
          ...extraHeaders,
        },
      });

    const proxyJson = async (targetUrl, bodyObj) => {
      const r = await fetch(targetUrl, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(bodyObj),
      });
      const text = await r.text();
      return new Response(text, {
        status: r.status,
        headers: {
          "content-type": "application/json; charset=utf-8",
          "cache-control": "no-store",
          ...corsHeaders,
        },
      });
    };

    // ===== API: register =====
    if (request.method === "POST" && path === "/api/register") {
      return proxyJson(`${OIDC}/client/register`, {
        clientName: "Amazon Q Developer for command line",
        clientType: "public",
        scopes: [
          "codewhisperer:completions",
          "codewhisperer:analysis",
          "codewhisperer:conversations",
        ],
      });
    }

    // ===== API: device authorization =====
    if (request.method === "POST" && path === "/api/device") {
      let req;
      try {
        req = await request.json();
      } catch {
        req = {};
      }
      const { clientId, clientSecret } = req || {};
      if (!clientId || !clientSecret) {
        return json({ error: "missing clientId/clientSecret" }, 400);
      }
      return proxyJson(`${OIDC}/device_authorization`, {
        clientId,
        clientSecret,
        startUrl: START_URL,
      });
    }

    // ===== API: poll token (device_code) =====
    // UI handles 400 authorization_pending as "Pending"
    if (request.method === "POST" && path === "/api/poll") {
      let req;
      try {
        req = await request.json();
      } catch {
        req = {};
      }
      const { clientId, clientSecret, deviceCode } = req || {};
      if (!clientId || !clientSecret || !deviceCode) {
        return json({ error: "missing clientId/clientSecret/deviceCode" }, 400);
      }
      return proxyJson(`${OIDC}/token`, {
        clientId,
        clientSecret,
        deviceCode,
        grantType: "urn:ietf:params:oauth:grant-type:device_code",
      });
    }

    // ===== API: refresh (optional) =====
    if (request.method === "POST" && path === "/api/refresh") {
      let req;
      try {
        req = await request.json();
      } catch {
        req = {};
      }
      const { clientId, clientSecret, refreshToken } = req || {};
      if (!clientId || !clientSecret || !refreshToken) {
        return json({ error: "missing clientId/clientSecret/refreshToken" }, 400);
      }
      return proxyJson(`${OIDC}/token`, {
        clientId,
        clientSecret,
        refreshToken,
        grantType: "refresh_token",
      });
    }

    return new Response("Not Found", { status: 404 });
  },
};

function renderHTML() {
  const AWS_SVG = `<svg fill="currentColor" fill-rule="evenodd" height="18" viewBox="0 0 24 24" width="18" xmlns="http://www.w3.org/2000/svg" style="flex: 0 0 auto; line-height: 1;"><title>AWS</title><path d="M6.763 11.212c0 .296.032.535.088.71.064.176.144.368.256.576.04.063.056.127.056.183 0 .08-.048.16-.152.24l-.503.335a.383.383 0 01-.208.072c-.08 0-.16-.04-.239-.112a2.47 2.47 0 01-.287-.375 6.18 6.18 0 01-.248-.471c-.622.734-1.405 1.101-2.347 1.101-.67 0-1.205-.191-1.596-.574-.39-.384-.59-.894-.59-1.533 0-.678.24-1.23.726-1.644.487-.415 1.133-.623 1.955-.623.272 0 .551.024.846.064.296.04.6.104.918.176v-.583c0-.607-.127-1.03-.375-1.277-.255-.248-.686-.367-1.3-.367-.28 0-.568.031-.863.103-.295.072-.583.16-.862.272a2.4 2.4 0 01-.28.104.488.488 0 01-.127.023c-.112 0-.168-.08-.168-.247v-.391c0-.128.016-.224.056-.28a.597.597 0 01.224-.167 4.577 4.577 0 011.005-.36 4.84 4.84 0 011.246-.151c.95 0 1.644.216 2.091.647.44.43.662 1.085.662 1.963v2.586h.016zm-3.24 1.214c.263 0 .534-.048.822-.144a1.78 1.78 0 00.758-.51 1.27 1.27 0 00.272-.512c.047-.191.08-.423.08-.694v-.335a6.66 6.66 0 00-.735-.136 6.02 6.02 0 00-.75-.048c-.535 0-.926.104-1.19.32-.263.215-.39.518-.39.917 0 .375.095.655.295.846.191.2.47.296.838.296zm6.41.862c-.144 0-.24-.024-.304-.08-.064-.048-.12-.16-.168-.311L7.586 6.726a1.398 1.398 0 01-.072-.32c0-.128.064-.2.191-.2h.783c.151 0 .255.025.31.08.065.048.113.16.16.312l1.342 5.284 1.245-5.284c.04-.16.088-.264.151-.312a.549.549 0 01.32-.08h.638c.152 0 .256.025.32.08.063.048.12.16.151.312l1.261 5.348 1.381-5.348c.048-.16.104-.264.16-.312a.52.52 0 01.311-.08h.743c.127 0 .2.065.2.2 0 .04-.009.08-.017.128a1.137 1.137 0 01-.056.2l-1.923 6.17c-.048.16-.104.263-.168.311a.51.51 0 01-.303.08h-.687c-.15 0-.255-.024-.32-.08-.063-.056-.119-.16-.15-.32L12.32 7.747l-1.23 5.14c-.04.16-.087.264-.15.32-.065.056-.177.08-.32.08l-.686.001zm10.256.215c-.415 0-.83-.048-1.229-.143-.399-.096-.71-.2-.918-.32-.128-.071-.215-.151-.247-.223a.563.563 0 01-.048-.224v-.407c0-.167.064-.247.183-.247.048 0 .096.008.144.024.048.016.12.048.2.08.271.12.566.215.878.279.32.064.63.096.95.096.502 0 .894-.088 1.165-.264a.86.86 0 00.415-.758.777.777 0 00-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.902 1.902 0 01-.4-1.158c0-.335.073-.63.216-.886.144-.255.335-.479.575-.654.24-.184.51-.32.83-.415.32-.096.655-.136 1.006-.136.175 0 .36.008.535.032.183.024.35.056.518.088.16.04.312.08.455.127.144.048.256.096.336.144a.69.69 0 01.24.2.43.43 0 01.071.263v.375c0 .168-.064.256-.184.256a.83.83 0 01-.303-.096 3.652 3.652 0 00-1.532-.311c-.455 0-.815.071-1.062.223-.248.152-.375.383-.375.71 0 .224.08.416.24.567.16.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767.247.327.367.702.367 1.117 0 .343-.072.655-.207.926a2.157 2.157 0 01-.583.703c-.248.2-.543.343-.886.447-.36.111-.734.167-1.142.167z"></path><path d="M.378 15.475c3.384 1.963 7.56 3.153 11.877 3.153 2.914 0 6.114-.607 9.06-1.852.44-.2.814.287.383.607-2.626 1.94-6.442 2.969-9.722 2.969-4.598 0-8.74-1.7-11.87-4.526-.247-.223-.024-.527.272-.351zm23.531-.2c.287.36-.08 2.826-1.485 4.007-.215.184-.423.088-.327-.151l.175-.439c.343-.88.802-2.198.52-2.555-.336-.43-2.22-.207-3.074-.103-.255.032-.295-.192-.063-.36 1.5-1.053 3.967-.75 4.254-.399z" fill="#F90"></path></svg>`;

  return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Kiro Manual Auth (Worker)</title>
<style>
  :root{
    --bg: 10 10 12;
    --card: 18 18 22;
    --muted: 160 160 175;
    --text: 240 240 245;
    --border: 255 255 255;
    --shadow: 0 10px 30px rgba(0,0,0,.35);
    --ring: 99 102 241;
    --ok: 16 185 129;
    --bad: 239 68 68;
    --warn: 245 158 11;
  }
  *{box-sizing:border-box}
  html,body{height:100%}
  body{
    margin:0;
    font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
    color: rgb(var(--text));
    background:
      radial-gradient(1200px 600px at 20% 10%, rgba(99,102,241,.22), transparent 60%),
      radial-gradient(900px 520px at 80% 30%, rgba(16,185,129,.18), transparent 55%),
      radial-gradient(900px 520px at 40% 90%, rgba(236,72,153,.14), transparent 55%),
      linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.35) 55%, rgba(0,0,0,.75) 100%),
      rgb(var(--bg));
    overflow-x:hidden;
  }

  .container{max-width:980px;margin:0 auto;padding:40px 16px 60px}
  .header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px}
  .title{font-size:22px;font-weight:650;letter-spacing:-.02em;margin:0}
  .subtitle{margin:8px 0 0;color:rgba(var(--muted),.9);font-size:13px;line-height:1.5}

  .card{
    border:1px solid rgba(var(--border),.10);
    background: rgba(var(--card), .55);
    backdrop-filter: blur(16px);
    -webkit-backdrop-filter: blur(16px);
    border-radius: 18px;
    box-shadow: var(--shadow);
    overflow:hidden;
    position:relative;
  }
  .card-inner{padding:18px}
  .row{display:flex;gap:12px;flex-wrap:wrap;align-items:center;justify-content:space-between}
  .muted{color:rgba(var(--muted),.9);font-size:12px;line-height:1.5}
  .sep{height:1px;background:rgba(var(--border),.10);margin:16px 0}

  .btn{
    display:inline-flex;align-items:center;gap:10px;
    padding:10px 14px;
    border-radius:14px;
    border:1px solid rgba(var(--border),.14);
    background: rgba(255,255,255,.06);
    color: rgb(var(--text));
    cursor:pointer;
    font-size:13px;font-weight:600;
    transition: transform .12s ease, background .12s ease, border-color .12s ease, box-shadow .12s ease;
    user-select:none;
  }
  .btn:hover{background: rgba(255,255,255,.09);border-color: rgba(var(--border),.20);transform: translateY(-1px)}
  .btn:active{transform: translateY(0px)}
  .btn:focus{outline:none;box-shadow: 0 0 0 4px rgba(var(--ring), .25)}
  .btn[disabled]{opacity:.55;cursor:not-allowed;transform:none}
  .btn-primary{background: rgba(255,255,255,.10);border-color: rgba(255,255,255,.16)}
  .btn-ghost{background: transparent;border-color: rgba(255,255,255,.10)}
  .btn-secondary{background: rgba(255,255,255,.07)}
  .btn-xs{padding:7px 10px;border-radius:12px;font-size:12px}

  .grid{display:grid;grid-template-columns:1fr;gap:12px}
  @media (min-width: 860px){ .grid{grid-template-columns:1fr 1fr} }

  .field{
    border:1px solid rgba(var(--border),.10);
    background: rgba(0,0,0,.22);
    border-radius: 16px;
    padding:12px 14px;
  }
  .label{font-size:11px;color:rgba(var(--muted),.9);margin-bottom:6px}
  .value{font-size:13px;word-break:break-all}
  .mono{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size:12px}

  .link-panel{
    position:relative;
    border:1px solid rgba(var(--border),.10);
    background: rgba(0,0,0,.20);
    border-radius: 18px;
    padding:14px;
    overflow:hidden;
    transition: box-shadow .16s ease, border-color .16s ease, transform .16s ease;
  }
  .link-panel:hover{box-shadow: 0 12px 30px rgba(0,0,0,.35);border-color: rgba(255,255,255,.16);transform: translateY(-1px)}
  .link-text{margin-top:8px;font-size:13px;opacity:.92;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}

  /* Acrylic hover overlay */
  .acrylic{position:absolute;inset:0;opacity:0;transition: opacity .16s ease;pointer-events:none;}
  .link-panel:hover .acrylic{opacity:1}
  .acrylic::before{
    content:"";position:absolute;inset:0;
    background: rgba(255,255,255,.10);
    backdrop-filter: blur(18px);
    -webkit-backdrop-filter: blur(18px);
  }
  .acrylic::after{
    content:"";position:absolute;inset:-40px;
    background: radial-gradient(420px 220px at 20% 20%, rgba(255,255,255,.12), transparent 55%),
                radial-gradient(420px 220px at 80% 40%, rgba(255,255,255,.08), transparent 55%);
    opacity:.9;mix-blend-mode: overlay;
  }
  .link-actions{
    position:absolute; inset:0;
    display:flex;align-items:center;justify-content:center;gap:10px;
    opacity:0;transition: opacity .16s ease;
    pointer-events:none;
  }
  .link-panel:hover .link-actions{opacity:1}
  .link-actions .btn{pointer-events:auto}

  .status{
    border:1px solid rgba(var(--border),.10);
    background: rgba(0,0,0,.20);
    border-radius: 16px;
    padding:12px 14px;
    display:flex;gap:12px;align-items:flex-start;justify-content:space-between;
  }
  .badge{
    display:inline-flex;align-items:center;gap:8px;
    font-size:11px;color:rgba(var(--muted),.95);
    padding:6px 10px;border-radius:999px;
    border:1px solid rgba(var(--border),.12);
    background: rgba(255,255,255,.05);
    white-space:nowrap;
  }
  .dot{width:8px;height:8px;border-radius:999px;background: rgba(255,255,255,.55);position:relative;}
  .dot.ping::after{
    content:"";position:absolute;inset:-6px;border-radius:999px;
    border:1px solid rgba(255,255,255,.35);
    animation: ping 1.2s ease-out infinite;opacity:.8;
  }
  @keyframes ping{0%{transform:scale(.4);opacity:.8} 100%{transform:scale(1.5);opacity:0}}

  .status.ok{border-color: rgba(var(--ok), .35); background: rgba(var(--ok), .10)}
  .status.bad{border-color: rgba(var(--bad), .35); background: rgba(var(--bad), .10)}
  .status.warn{border-color: rgba(var(--warn), .35); background: rgba(var(--warn), .08)}

  /* Modal */
  .modal-backdrop{
    position:fixed;inset:0;
    background: rgba(0,0,0,.60);
    display:none;
    align-items:center;justify-content:center;
    padding:18px;
    z-index:50;
  }
  .modal-backdrop.show{display:flex}
  .modal{
    width:min(900px, 100%);
    max-height: 88vh; /* NEW: keep modal within viewport */
    border-radius: 18px;
    border:1px solid rgba(var(--border),.14);
    background: rgba(var(--card), .86);
    backdrop-filter: blur(18px);
    -webkit-backdrop-filter: blur(18px);
    box-shadow: var(--shadow);
    overflow:hidden;
    display:flex;
    flex-direction:column; /* NEW: allow internal scroll areas */
  }
  .modal-head{padding:16px 18px;border-bottom:1px solid rgba(var(--border),.10);flex:0 0 auto}
  .modal-title{margin:0;font-size:16px;font-weight:750}
  .modal-desc{margin:6px 0 0;color:rgba(var(--muted),.9);font-size:12px;line-height:1.45}

  .modal-body{
    padding:16px 18px;
    flex: 1 1 auto;          /* NEW */
    min-height: 0;           /* NEW: critical for flex scroll children */
    display:flex;            /* NEW */
    flex-direction:column;   /* NEW */
    gap:12px;                /* NEW */
  }

  /* NEW: scrollable content area inside modal body */
  .modal-scroll{
    flex: 1 1 auto;
    min-height: 0;
    overflow:auto;
    padding-right: 4px;
    border-radius: 14px;
  }
  /* nicer scrollbar (webkit only) */
  .modal-scroll::-webkit-scrollbar{width:10px}
  .modal-scroll::-webkit-scrollbar-thumb{background: rgba(255,255,255,.12); border-radius: 999px; border:2px solid rgba(0,0,0,.15)}
  .modal-scroll::-webkit-scrollbar-track{background: rgba(0,0,0,.10); border-radius: 999px}

  pre{
    margin:0;border-radius: 14px;border:1px solid rgba(var(--border),.10);
    background: rgba(0,0,0,.30);
    padding:14px;
    overflow:auto;
    font-size:12px; line-height:1.5;
  }

  /* Footer pinned inside modal */
  .footer-actions{
    display:flex;gap:10px;flex-wrap:wrap;
    padding-top: 12px;
    margin-top: 0;
    position: sticky;     /* NEW */
    bottom: 0;            /* NEW */
    background: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,.20) 30%, rgba(0,0,0,.28));
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    border-top: 1px solid rgba(255,255,255,.08);
  }
  .right{margin-left:auto}

  /* Tabs */
  .tabs{display:flex;gap:8px;align-items:center}
  .tab{
    padding:8px 10px;
    border-radius: 12px;
    border:1px solid rgba(var(--border),.12);
    background: rgba(255,255,255,.05);
    color: rgba(var(--text), .88);
    font-size:12px;font-weight:650;
    cursor:pointer;
    transition: background .12s ease, border-color .12s ease, transform .12s ease;
    user-select:none;
  }
  .tab:hover{background: rgba(255,255,255,.08);border-color: rgba(255,255,255,.18);transform: translateY(-1px)}
  .tab.active{
    background: rgba(255,255,255,.12);
    border-color: rgba(255,255,255,.22);
    color: rgb(var(--text));
  }

  /* Viewer list */
  .viewer{display:flex;flex-direction:column;gap:10px;}
  .kv-row{
    position:relative;
    border:1px solid rgba(var(--border),.10);
    background: rgba(0,0,0,.22);
    border-radius: 16px;
    padding:12px 14px;
    overflow:hidden;
  }
  .kv-row:hover{border-color: rgba(255,255,255,.16)}
  .kv-key{font-size:11px;color:rgba(var(--muted),.92)}
  .kv-val{
    margin-top:6px;
    font-size:12px;
    word-break: break-all;
    opacity:.92;

    /* NEW: prevent a single token from taking the entire screen */
    max-height: 140px;
    overflow:auto;
    padding-right: 6px;
  }
  .kv-val::-webkit-scrollbar{width:10px}
  .kv-val::-webkit-scrollbar-thumb{background: rgba(255,255,255,.10); border-radius: 999px; border:2px solid rgba(0,0,0,.15)}
  .kv-val::-webkit-scrollbar-track{background: rgba(0,0,0,.10); border-radius: 999px}

  .kv-actions{
    position:absolute;inset:0;
    display:flex;align-items:center;justify-content:center;
    opacity:0;transition: opacity .16s ease;
    pointer-events:none;
  }
  .kv-row:hover .kv-actions{opacity:1}
  .kv-actions .btn{pointer-events:auto}

  .kv-acrylic{position:absolute;inset:0;opacity:0;transition: opacity .16s ease;pointer-events:none;}
  .kv-row:hover .kv-acrylic{opacity:1}
  .kv-acrylic::before{
    content:"";position:absolute;inset:0;
    background: rgba(255,255,255,.10);
    backdrop-filter: blur(18px);
    -webkit-backdrop-filter: blur(18px);
  }
  .kv-acrylic::after{
    content:"";position:absolute;inset:-40px;
    background: radial-gradient(380px 200px at 25% 30%, rgba(255,255,255,.12), transparent 58%),
                radial-gradient(380px 200px at 75% 45%, rgba(255,255,255,.08), transparent 60%);
    opacity:.9;mix-blend-mode: overlay;
  }

  .small-note{margin-top:12px;color:rgba(var(--muted),.85);font-size:12px;line-height:1.5}
  .glow{
    position:absolute; inset:-200px;
    background: radial-gradient(600px 260px at 20% 10%, rgba(99,102,241,.20), transparent 55%),
                radial-gradient(500px 240px at 80% 20%, rgba(16,185,129,.14), transparent 60%);
    pointer-events:none;
    opacity:.9;
  }
</style>
</head>

<body>
  <div class="container">
    <div class="header">
      <div>
        <h1 class="title">Kiro Manual Auth</h1>
        <p class="subtitle">
          Click <b>Login</b> → get CID/CS → device flow link. Hover link for <b>Copy | Open Link</b>.
          We poll automatically and pop credentials when authorized.
        </p>
      </div>
      <button id="btnLogin" class="btn btn-primary">
        ${AWS_SVG}
        <span>Login</span>
      </button>
    </div>

    <div class="card">
      <div class="glow"></div>
      <div class="card-inner">
        <div class="row" style="gap:10px">
          <div class="muted">
            OIDC requests are proxied server-side to bypass browser CORS.
            <span style="display:block;opacity:.85">Treat Refresh Token / Client Secret as secrets.</span>
          </div>
          <div class="row" style="justify-content:flex-end">
            <button id="btnClear" class="btn btn-ghost">Clear</button>
          </div>
        </div>

        <div class="sep"></div>

        <div class="link-panel" id="linkPanel">
          <div class="label">Device Flow Link</div>
          <div class="link-text" id="verifyUrl" title="">—</div>

          <div class="acrylic"></div>
          <div class="link-actions" id="linkActions">
            <button id="btnCopyLink" class="btn btn-secondary">Copy</button>
            <button id="btnOpenLink" class="btn btn-secondary">Open Link</button>
          </div>
        </div>

        <div class="sep"></div>

        <div class="grid">
          <div class="field">
            <div class="label">User Code</div>
            <div class="value" id="userCode">—</div>
          </div>
          <div class="field">
            <div class="label">Device Code</div>
            <div class="value mono" id="deviceCode">—</div>
          </div>
        </div>

        <div class="sep"></div>

        <div class="status" id="statusBox">
          <div>
            <div style="font-weight:700;font-size:13px">Status</div>
            <div class="muted" id="statusText" style="margin-top:4px">Click Login to start.</div>
          </div>
          <div class="badge" id="statusBadge">
            <span class="dot" id="statusDot"></span>
            <span id="statusLabel">IDLE</span>
          </div>
        </div>

        <div class="small-note">
          Tip: hover the link panel to copy/open. Polling starts immediately after link generation.
        </div>
      </div>
    </div>
  </div>

  <!-- Modal -->
  <div class="modal-backdrop" id="modalBackdrop" role="dialog" aria-modal="true">
    <div class="modal">
      <div class="modal-head">
        <div class="row" style="align-items:flex-start">
          <div>
            <h2 class="modal-title">Authorized ✅</h2>
            <p class="modal-desc">Viewer: copy per key (CID/CS included). JSON: raw token.json.</p>
          </div>
          <div class="tabs" aria-label="result tabs">
            <div id="tabViewer" class="tab active">Viewer</div>
            <div id="tabJson" class="tab">JSON</div>
          </div>
        </div>
      </div>

      <div class="modal-body">
        <!-- NEW: scroll container -->
        <div class="modal-scroll" id="modalScroll">
          <div id="panelViewer" class="viewer"></div>

          <div id="panelJson" style="display:none">
            <pre id="resultPre">{}</pre>
          </div>
        </div>

        <div class="footer-actions">
          <button id="btnCopyJson" class="btn btn-secondary">Copy JSON</button>
          <button id="btnDownload" class="btn btn-secondary">Download token.json</button>
          <button id="btnCloseModal" class="btn btn-ghost right">Close</button>
        </div>
      </div>
    </div>
  </div>

<script>
(() => {
  const $ = (id) => document.getElementById(id);

  const state = {
    loading: false,
    clientId: "",
    clientSecret: "",
    verifyUrl: "",
    userCode: "",
    deviceCode: "",

    // polling control: no overlap; next only after last finished
    pollTimer: null,
    pollIntervalMs: 2000,
    pollInFlight: false,
    pollingEnabled: false,

    lastPollOk: null,   // last successful poll JSON (accessToken present)
    resultJson: "",     // token.json content shown in JSON tab
  };

  const ui = {
    btnLogin: $("btnLogin"),
    btnClear: $("btnClear"),
    verifyUrl: $("verifyUrl"),
    userCode: $("userCode"),
    deviceCode: $("deviceCode"),
    btnCopyLink: $("btnCopyLink"),
    btnOpenLink: $("btnOpenLink"),

    statusBox: $("statusBox"),
    statusText: $("statusText"),
    statusDot: $("statusDot"),
    statusLabel: $("statusLabel"),

    modalBackdrop: $("modalBackdrop"),
    modalScroll: $("modalScroll"),
    panelViewer: $("panelViewer"),
    panelJson: $("panelJson"),
    resultPre: $("resultPre"),
    btnCopyJson: $("btnCopyJson"),
    btnDownload: $("btnDownload"),
    btnCloseModal: $("btnCloseModal"),

    tabViewer: $("tabViewer"),
    tabJson: $("tabJson"),
  };

  function escapeHtml(str) {
    return String(str)
      .replaceAll("&", "&amp;")
      .replaceAll("<", "&lt;")
      .replaceAll(">", "&gt;")
      .replaceAll('"', "&quot;")
      .replaceAll("'", "&#039;");
  }

  function setBusy(b) {
    state.loading = b;
    ui.btnLogin.disabled = b;
    ui.btnClear.disabled = b;
  }

  function setStatus(kind, text, label) {
    ui.statusText.textContent = text;
    ui.statusLabel.textContent = label;

    ui.statusBox.classList.remove("ok", "bad", "warn");
    ui.statusDot.classList.remove("ping");
    ui.statusDot.style.background = "rgba(255,255,255,.55)";

    if (kind === "pending" || kind === "polling") {
      ui.statusBox.classList.add("warn");
      ui.statusDot.classList.add("ping");
      ui.statusDot.style.background = "rgba(245,158,11,.9)";
    } else if (kind === "ok") {
      ui.statusBox.classList.add("ok");
      ui.statusDot.style.background = "rgba(16,185,129,.95)";
    } else if (kind === "error") {
      ui.statusBox.classList.add("bad");
      ui.statusDot.style.background = "rgba(239,68,68,.95)";
    }
  }

  function stopPolling() {
    state.pollingEnabled = false;
    if (state.pollTimer) clearTimeout(state.pollTimer);
    state.pollTimer = null;
    state.pollInFlight = false;
  }

  function scheduleNextPoll() {
    if (!state.pollingEnabled) return;
    if (state.pollTimer) clearTimeout(state.pollTimer);

    state.pollTimer = setTimeout(async () => {
      await pollOnce();
      scheduleNextPoll();
    }, state.pollIntervalMs);
  }

  async function apiStrict(path, body) {
    const r = await fetch(path, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: body ? JSON.stringify(body) : undefined
    });
    const data = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(typeof data === "string" ? data : JSON.stringify(data));
    return data;
  }

  async function apiAllowNon200(path, body) {
    const r = await fetch(path, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: body ? JSON.stringify(body) : undefined
    });
    const data = await r.json().catch(() => ({}));
    return { ok: r.ok, status: r.status, data };
  }

  function setLink(url) {
    state.verifyUrl = url || "";
    ui.verifyUrl.textContent = url || "—";
    ui.verifyUrl.title = url || "";
    ui.btnCopyLink.disabled = !url;
    ui.btnOpenLink.disabled = !url;
  }

  function setCodes({ userCode, deviceCode }) {
    state.userCode = userCode || "";
    state.deviceCode = deviceCode || "";
    ui.userCode.textContent = userCode || "—";
    ui.deviceCode.textContent = deviceCode || "—";
  }

  async function copy(text) {
    if (!text) return;
    await navigator.clipboard.writeText(text);
  }

  function showModal() {
    ui.modalBackdrop.classList.add("show");
    // NEW: reset scroll to top when opening (optional but nice)
    if (ui.modalScroll) ui.modalScroll.scrollTop = 0;
  }
  function hideModal() {
    ui.modalBackdrop.classList.remove("show");
  }

  function setTab(which) {
    const viewer = which === "viewer";
    ui.tabViewer.classList.toggle("active", viewer);
    ui.tabJson.classList.toggle("active", !viewer);
    ui.panelViewer.style.display = viewer ? "flex" : "none";
    ui.panelJson.style.display = viewer ? "none" : "block";
    // keep scroll at top when switching
    if (ui.modalScroll) ui.modalScroll.scrollTop = 0;
  }

  function kvRow(key, val, onCopy) {
    const row = document.createElement("div");
    row.className = "kv-row";
    row.innerHTML = \`
      <div class="kv-key">\${escapeHtml(key)}</div>
      <div class="kv-val mono">\${escapeHtml(val)}</div>
      <div class="kv-acrylic"></div>
      <div class="kv-actions">
        <button class="btn btn-secondary btn-xs">Copy</button>
      </div>
    \`;
    const btn = row.querySelector("button");
    btn.addEventListener("click", onCopy);
    return row;
  }

  function normalizeValue(v) {
    if (v === null || v === undefined) return "null";
    if (typeof v === "string") return v;
    return JSON.stringify(v);
  }

  function renderViewer({ cid, cs, pollOk }) {
    ui.panelViewer.innerHTML = "";

    const cidVal = cid || "";
    const csVal = cs || "";

    ui.panelViewer.appendChild(
      kvRow("clientId", cidVal || "—", async () => {
        await copy(cidVal);
        setStatus("ok", "Copied: clientId", "SUCCESS");
      })
    );

    ui.panelViewer.appendChild(
      kvRow("clientSecret", csVal || "—", async () => {
        await copy(csVal);
        setStatus("ok", "Copied: clientSecret", "SUCCESS");
      })
    );

    if (!pollOk || typeof pollOk !== "object") {
      const msg = document.createElement("div");
      msg.className = "muted";
      msg.textContent = "No poll response data.";
      ui.panelViewer.appendChild(msg);
      return;
    }

    const entries = Object.entries(pollOk);
    for (const [k, v] of entries) {
      const val = normalizeValue(v);
      ui.panelViewer.appendChild(
        kvRow(k, val, async () => {
          await copy(val);
          setStatus("ok", "Copied: " + k, "SUCCESS");
        })
      );
    }
  }

  function buildTokenJson(cid, cs, pollOk) {
    const out = {
      client_id: cid,
      client_secret: cs,
      refresh_token: pollOk && pollOk.refreshToken ? pollOk.refreshToken : null,
      poll_response: pollOk || null
    };
    return JSON.stringify(out, null, 2);
  }

  async function pollOnce() {
    if (!state.pollingEnabled) return;
    if (state.pollInFlight) return;

    state.pollInFlight = true;

    try {
      const { ok, status, data } = await apiAllowNon200("/api/poll", {
        clientId: state.clientId,
        clientSecret: state.clientSecret,
        deviceCode: state.deviceCode,
      });

      if (data && data.accessToken) {
        state.lastPollOk = data;
        stopPolling();
        setStatus("ok", "Authorized! Refresh token received.", "SUCCESS");

        state.resultJson = buildTokenJson(state.clientId, state.clientSecret, data);
        ui.resultPre.textContent = state.resultJson;

        renderViewer({ cid: state.clientId, cs: state.clientSecret, pollOk: data });

        setTab("viewer");
        showModal();
        return;
      }

      // PENDING (even if backend returns 400)
      if (data && data.error === "authorization_pending") {
        setStatus("pending", "Pending authorization... (complete it in the opened page)", "PENDING");
        return;
      }

      if (data && data.error === "slow_down") {
        state.pollIntervalMs = Math.min(state.pollIntervalMs + 2000, 10000);
        setStatus(
          "pending",
          "Slow down requested. Polling every " + (state.pollIntervalMs / 1000).toFixed(1) + "s",
          "PENDING"
        );
        return;
      }

      if (data && data.error === "expired_token") {
        stopPolling();
        setStatus("error", "Device code expired. Click Login again.", "EXPIRED");
        return;
      }

      if (!ok) {
        stopPolling();
        setStatus("error", "Error: " + JSON.stringify(data), "ERROR");
        return;
      }

      setStatus("pending", "Waiting... " + JSON.stringify(data), "PENDING");
    } catch (e) {
      stopPolling();
      setStatus("error", "Polling error: " + (e && e.message ? e.message : String(e)), "ERROR");
    } finally {
      state.pollInFlight = false;
    }
  }

  function startPolling() {
    stopPolling();
    state.pollingEnabled = true;
    setStatus("pending", "Polling started... authorize in the verification page.", "PENDING");

    pollOnce().finally(() => {
      scheduleNextPoll();
    });
  }

  async function loginFlow() {
    stopPolling();
    setBusy(true);

    setLink("");
    setCodes({ userCode: "", deviceCode: "" });
    state.clientId = "";
    state.clientSecret = "";
    state.lastPollOk = null;
    state.resultJson = "";

    setStatus("polling", "Registering OIDC client...", "WORKING");

    try {
      const reg = await apiStrict("/api/register");
      state.clientId = reg.clientId;
      state.clientSecret = reg.clientSecret;

      setStatus("polling", "Generating device flow link...", "WORKING");

      const dev = await apiStrict("/api/device", {
        clientId: state.clientId,
        clientSecret: state.clientSecret
      });

      const link = dev.verificationUriComplete || dev.verificationUri || "";
      setLink(link);
      setCodes({ userCode: dev.userCode, deviceCode: dev.deviceCode });

      state.pollIntervalMs = Math.max(2000, (dev.interval ? dev.interval * 1000 : 2000));
      setStatus("pending", "Link ready. Hover to Copy/Open. Polling in background...", "PENDING");

      startPolling();
    } catch (e) {
      stopPolling();
      setStatus("error", "Error: " + (e && e.message ? e.message : String(e)), "ERROR");
    } finally {
      setBusy(false);
    }
  }

  function clearAll() {
    stopPolling();
    state.clientId = "";
    state.clientSecret = "";
    state.verifyUrl = "";
    state.userCode = "";
    state.deviceCode = "";
    state.lastPollOk = null;
    state.resultJson = "";
    state.pollIntervalMs = 2000;

    setLink("");
    setCodes({ userCode: "", deviceCode: "" });
    setStatus("idle", "Cleared. Click Login to start.", "IDLE");
    hideModal();
  }

  // ===== Bindings =====
  ui.btnLogin.addEventListener("click", loginFlow);
  ui.btnClear.addEventListener("click", clearAll);

  ui.btnCopyLink.addEventListener("click", async () => {
    if (!state.verifyUrl) return;
    await copy(state.verifyUrl);
    setStatus("pending", "Link copied. Continue authorization in the opened page.", "PENDING");
  });

  ui.btnOpenLink.addEventListener("click", () => {
    if (!state.verifyUrl) return;
    window.open(state.verifyUrl, "_blank", "noopener,noreferrer");
    setStatus("pending", "Link opened. Complete authorization, we are polling...", "PENDING");
  });

  ui.btnCopyJson.addEventListener("click", async () => {
    if (!state.resultJson) return;
    await copy(state.resultJson);
    setStatus("ok", "token.json copied.", "SUCCESS");
  });

  ui.btnDownload.addEventListener("click", () => {
    if (!state.resultJson) return;
    const blob = new Blob([state.resultJson], { type: "application/json" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = "token.json";
    a.click();
    URL.revokeObjectURL(a.href);
    setStatus("ok", "Downloaded token.json", "SUCCESS");
  });

  ui.btnCloseModal.addEventListener("click", hideModal);

  ui.modalBackdrop.addEventListener("click", (e) => {
    if (e.target === ui.modalBackdrop) hideModal();
  });

  document.addEventListener("keydown", (e) => {
    if (e.key === "Escape") hideModal();
  });

  ui.tabViewer.addEventListener("click", () => setTab("viewer"));
  ui.tabJson.addEventListener("click", () => setTab("json"));

  // Initial state
  setLink("");
  setCodes({ userCode: "", deviceCode: "" });
  setStatus("idle", "Click Login to start.", "IDLE");
  setTab("viewer");
})();
</script>
</body>
</html>`;
}

使用方法:


📌 转载信息
转载时间:
2026/1/15 18:19:11

众所周知,Anyrouter 用了 acw_sc__v2 动态 Cookie 做反爬,导致查询余额脚本、签到脚本都需要这个动态的 Cookie,这里记录一下怎么绕过。

虽然站内已经有现成的方案,但是这位佬的无法支持 Cloudflare Worker 部署,着实有些不方便

混淆代码分析

当 Cookie 中不存在 acw_sc__v2 时,返回的 HTML 大致结构如下:

<html> <script>var arg1 = ;
(function(a, c) {
// 大量混淆代码...
}(a0i, ), !(function() {
// 核心逻辑在这里
}()));
function a0i() { /* 字符串数组 */ }
function a0j(a, b) { /* 解码函数 */ }
</script> </html>

代码使用了常见的混淆技术:

  • 字符串数组 + 解码函数
  • 变量名替换为十六进制
  • 控制流平坦化

核心算法提取

去除混淆后,核心逻辑如下:

// 输入:arg1 是一个 40 位的十六进制字符串 var arg1 = ;

// 置换表(固定) var m = [0xf, , , , , , , ,
         0xa, , , , , , , ,
         , 0xd, , 0xb, , , , ,
         0xe, , , , , , , ,
         , , , , , , 0xc, ];

// XOR 密钥(固定) var p = ;

// Step 1: unsbox - 字符重排 var q = [];
for (var x = 0; x < arg1.length; x++) {
    for (var z = 0; z < m.length; z++) {
        if (m[z] == x + 1) {
            q[z] = arg1[x];
        }
    }
}
var u = q.join('');

// Step 2: hexXor - 十六进制异或 var v = '';
for (var i = 0; i < u.length; i += 2) {
    var a = parseInt(u.slice(i, i + 2), 16);
    var b = parseInt(p.slice(i, i + 2), 16);
    var xored = (a ^ b).toString(16);
    if (xored.length == 1) xored = '0' + xored;
    v += xored;
}

// 设置 Cookie document.cookie = 'acw_sc__v2=' + v + '; expires=...';
document.location.reload();

算法详解

Step 1: unsbox(字符重排)

置换表 m 定义了一个映射关系:m[z] = x + 1 表示输出的第 z 位来自输入的第 x 位。

换句话说,m 数组的值表示 "从输入的哪个位置取字符":

  • m[0] = 0xf = 15,输出第 0 位 = 输入第 15 位
  • ,输出第 1 位 = 输入第 35 位

简化实现:

const unsboxed = m.map(i => arg1[i - 1]).join('');

Step 2: hexXor(十六进制异或)

将重排后的字符串与固定密钥逐字节异或:

unsboxed:  XOR key:  result:  

异或运算每次处理 2 个十六进制字符(1 字节):

完整实现(TypeScript)

const XOR_KEY = ;
const UNSBOX_TABLE = [
  0xf, , , , , , , ,
  0xa, , , , , , , ,
  , 0xd, , 0xb, , , , ,
  0xe, , , , , , , ,
  , , , , , , 0xc, 
];

function computeAcwScV2(arg1: string): string {
  // unsbox: 根据置换表重排 const unsboxed = UNSBOX_TABLE.map(i => arg1[i - 1]).join('');

  // hexXor: 与 key 异或 let result = '';
  for (let i = 0; i < 40; i += 2) {
    const xored = (
      parseInt(unsboxed.slice(i, i + 2), 16) ^
      parseInt(XOR_KEY.slice(i, i + 2), 16)
    ).toString(16);
    result += xored.padStart(2, '0');
  }

  return result;
}

// 使用 const arg1 = ;
const cookie = computeAcwScV2(arg1);
 

在 Cloudflare Worker 中使用

由于算法是纯计算,不需要 eval,可以直接在 Cloudflare Worker 中运行:

async function getAcwCookie(targetUrl: URL): Promise<string | null> {
  const resp = await fetch(targetUrl.toString(), { redirect: 'manual' });
  const html = await resp.text();

  // 提取 arg1 const match = html.match(/var\s+arg1\s*=\s*'([0-9a-fA-F]+)'/);
  if (!match) return null;

  const cookie = computeAcwScV2(match[1]);
  return `acw_sc__v2=${cookie}`;
}

Worker 代码

直接往 Cloudflare 里一粘就完事了。不过这里也给佬们部署了一个现成的 Worker:https://anyrouter.devip.ip-ddns.com,佬们可以直接用。但如果爆了额度我可能会删掉

const UPSTREAM = 'https://anyrouter.top';
const XOR_KEY = ;
const UNSBOX_TABLE = [0xf, , , , , , , , 0xa, , , , , , , , , 0xd, , 0xb, , , , , 0xe, , , , , , , , , , , , , , 0xc, ];

export default {
  async fetch(req: Request): Promise<Response> {
    const url = new URL(req.url);
    if (url.pathname === '/') return new Response('OK');

    const targetUrl = new URL(url.pathname + url.search, UPSTREAM);
    const cookie = await getAcwCookie(targetUrl);
    if (!cookie) return new Response('Failed to obtain cookie', { status: 502 });

    const headers = new Headers(req.headers);
    headers.set('cookie', [cookie, req.headers.get('cookie')].filter(Boolean).join('; '));
    headers.set('origin', UPSTREAM);
    headers.set('referer', `${UPSTREAM}/`);
    headers.set('host', new URL(UPSTREAM).host);
    headers.delete('content-length');

    const init: RequestInit = { method: req.method, headers, redirect: 'manual' };
    if (!['GET', 'HEAD'].includes(req.method)) init.body = await req.arrayBuffer();

    const resp = await fetch(targetUrl.toString(), init);
    return new Response(resp.body, { status: resp.status, headers: resp.headers });
  },
};

async function getAcwCookie(targetUrl: URL): Promise<string | null> {
  try {
    const resp = await fetch(targetUrl.toString(), {
      headers: {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
      },
      redirect: 'manual',
    });
    const html = await resp.text();
    const match = html.match(/var\s+arg1\s*=\s*'([0-9a-fA-F]+)'/);
    if (!match) return null;

    // unsbox: 根据置换表重排 const unsboxed = UNSBOX_TABLE.map(i => match[1][i - 1]).join('');
    // hexXor: 与 key 异或 let result = '';
    for (let i = 0; i < 40; i += 2) {
      const xored = (parseInt(unsboxed.slice(i, i + 2), 16) ^ parseInt(XOR_KEY.slice(i, i + 2), 16)).toString(16);
      result += xored.padStart(2, '0');
    }
    return `acw_sc__v2=${result}`;
  } catch {
    return null;
  }
}

关于签到脚本

部署到 Cloudflare Worker 后,把所有对 Anyrouter 的请求全部替换成该 Worker 地址,即可。自动签到脚本需要 Cookie,目测有效期为一个月

站内已经有很多签到脚本了,佬们可以任意选择。如果后面有空我可能会写一个,但是感觉重复造轮子意义不大。

服务器
服务器 + 通知
Worker
二合一

参考资料

搞一个 AnyRouter 动态 cookie 验证(内含签到 + cc switch 查询余额) - 开发调优 - LINUX DO
雪球 JS 逆向:阿里系加密 acw_sc__v2 和 反 debugger_ 雪球网逆向 - CSDN 博客


📌 转载信息
转载时间:
2026/1/14 10:48:18

演示视频

这是原版脚本

bash <(curl -fsSL https://github.com/crazypeace/ghproxy/raw/refs/heads/main/fake/test-install.sh)

套 “正常” 的 ghproxy 这里以 https://ghproxy.lvedong.eu.org/ 为例

bash <(curl -fsSL https://ghproxy.lvedong.eu.org/https://github.com/crazypeace/ghproxy/raw/refs/heads/main/fake/test-install.sh)

套 “动过手脚” 的 ghproxy 这里以 https://ghproxy-fake.lvedong.eu.org/ 为例

bash <(curl -fsSL https://ghproxy-fake.lvedong.eu.org/https://github.com/crazypeace/ghproxy/raw/refs/heads/main/fake/test-install.sh)

可以看到 aff 链接被修改了

下载的 zip 文件解压缩 的结果也是不一样的

面向 GPT 开发

要实现这样的效果,没有开发能力的普通人借助 GPT 也能做到
上传 正常的 ghproxy 的 worker.js (以 ghproxy/worker.js at main · crazypeace/ghproxy · GitHub 为例)
向 GPT 发送 prompt

这是一个 cloudflare 的 worker 的 JS 脚本
增加以下处理:

1. 在对 .sh 文件的内容的处理中, 对链接的处理增加以下效果

当链接包含 racknerd 和 aff= 时, 
将aff= 后面的数字替换为 54321

当链接包含 justmysocks 和 aff= 时, 
将aff= 后面的数字替换为 98765

2. 在向目的url 进行 fetch之前 , 增加处理
当 path 是 https://github.com/SagerNet/sing-box/releases/download/v1.12.15/sing-box-1.12.15-windows-amd64.zip   时,
改为fetch https://github.com/XTLS/Xray-core/releases/download/v25.12.8/Xray-windows-64.zip 并返回数据

Github

https://github.com/crazypeace/ghproxy/raw/refs/heads/main/fake/test-worker.js

总结

ghproxy 还是要掌握在自己手里


📌 转载信息
原作者:
crazypeace
转载时间:
2026/1/12 15:47:07