收起左侧

零信任 = 绝对安全:CF 边缘白名单,无人能碰你的飞牛 NAS

0
回复
30
查看
[ 复制链接 ]

2

主题

5

回帖

0

牛值

江湖小虾

零信任 = 绝对安全:CF 边缘白名单,无人能碰你的飞牛 NAS

关键词:Cloudflare、Zero Trust、Access Policy、IP 白名单、内网穿透、安全


写在前面

自用NAS上的服务暴露到公网,最怕什么?不是带宽不够,不是配置复杂——是别人也能访问

哪怕用了 Cloudflare Tunnel,域名还是暴露的,谁都可以试试能不能打进去。

这套方案的核心是零信任:默认谁也不信,只信你的 IP。不在白名单里的请求,CF 边缘直接拒绝,连 NAS 的门都摸不到。

不止省资源(NAS 0 负载),更重要的是绝对安全——攻击流量在云端就被截停了。


搭建方式

本文提供两种方式:

  • 方式 A(AI 快速部署):适合不想手打命令行的用户,把信息喂给 AI 让它操作
  • 方式 B(手动部署):适合想自己一步一步跟着走的用户

两种方式最终效果完全一样,选你觉得舒服的就行。


一、GitHub 项目

所有代码放这里了:

https://github.com/mydelren/cloudflare-worker-ip-whitelist

里面有 Worker 代码、wrangler 部署模板、中英文 README、手机自动触发脚本。

如果能给个 Star,万分感谢。


二、思路

你的手机/电脑 → Cloudflare 边缘 → IP 在白名单?→ 放行 → 到达飞牛 NAS
                                  → IP 不在?  → 直接拒绝(连握手都没有)

这套方案的好处:

  • NAS 上 0 额外开销:不用跑 WAF、不用跑反代,省下的内存给真正的服务
  • ARM 低配 NAS 最友好:雷池(SafeLine)要 300MB+ 内存,这方案 0MB
  • 全都是免费额度:CF Tunnel 免费、CF Access 免费、CF Workers 免费(10 万次/天)

前提:你的服务是自用的,不是给陌生人公开的。


三、日常怎么用

收藏夹存一个 URL,平时正常访问。

IP 变了被拦截了?点一下收藏夹,5 秒恢复

不需要装 App,不需要学配置,不需要在 NAS 上额外跑容器。


四、原理

1. 手机打开 sync URL(Worker 返回 HTML 页面)
2. Worker 服务端记录 CF-Connecting-IP
3. 页面 JS 同时探测 IPv4(api.ipify.org)+ IPv6(api64.ipify.org)
4. 每个 IP 调用 add 接口 → 写入 KV 存储(每设备最多 8 个 IP)
5. Worker 从 KV 读取所有设备的 IP 列表
6. 合并固定 IP(如有)+ 动态 IP → 重建 include 数组
7. PUT 更新 CF Access Policy → 边缘 1-2 秒生效

五、方式 A:AI 快速部署

如果你不想手打命令行,把下面这些信息喂给 AI 工具,让它帮你操作。

适合的 AI 工具:需要支持运行终端命令的,比如 opencode、Claude Code、Cursor、TRAE、OpenClaw、Hermes Agent 等。网页版 AI(豆包、Kimi 网页版等)无法操作你的电脑,只能给建议,不适合这种方式。

5.1 准备信息

信息 在哪找
域名 你自己的
CF Account ID CF Dashboard 右上角 → 点邮箱
CF Access Policy ID Zero Trust → Access → Policies → 点你的策略 → 地址栏 UUID
CF API Token 右上角 → My Profile → API Tokens → 创建新 Token。权限选:Workers Scripts Edit、KV Storage Edit、Access: Apps and Policies Edit

5.2 喂给 AI

打开你的 AI 工具(比如 opencode、Claude Code、OpenClaw 等),把下面这段话发出去,替换成你自己的信息:

我有一台飞牛 NAS,没有公网 IP,用 Cloudflare Tunnel 做内网穿透。

请参考这个 GitHub 项目帮我部署:
https://github.com/mydelren/cloudflare-worker-ip-whitelist

我的信息:
- 域名:nas.你的域名.com
- CF Account ID:d20a63605308...
- CF Access Policy ID:5800b1ea-f35d-...
- CF API Token:cfut_...

请帮我一步步来。

5.3 你只需要配合几步

AI 会按照项目 README 指引你操作,你需要做的只有:

  1. 浏览器里创建 CF Access Policy(AI 会告诉你填什么)
  2. 给 Worker 配自定义域名(告诉你在哪点)
  3. 手机上收藏 sync URL

六、方式 B:手动部署(保姆级)

全部在 Cloudflare Dashboard 网页上操作,不需要装任何软件。

6.1 前置条件

6.2 创建 Worker

  1. 打开 CF DashboardWorkers & Pages
  2. Create applicationCreate Worker
  3. 给你的 Worker 起个名字,比如 cf-ip-whitelist
  4. Deploy

6.3 粘贴代码

  1. 在 Worker 编辑页面,全选 worker.js 默认代码,删掉
  2. 粘贴下面代码(来自 GitHub 项目,可以直接复制):
// Cloudflare Worker: Access Policy IP Whitelist Auto-Updater
// https://github.com/mydelren/cloudflare-worker-ip-whitelist

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

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

    const key = url.searchParams.get("key");
    const action = url.searchParams.get("action") || "sync";

    if (!key) {
      return htmlPage("Error", "<p>Missing key parameter</p>", 403);
    }

    const device = validateKey(key, env);
    if (!device) {
      return htmlPage("Error", "<p>Invalid key</p>", 403);
    }

    try {
      if (action === "sync") {
        return await handleSync(request, device, key, env);
      } else if (action === "add") {
        const ip = url.searchParams.get("ip");
        if (!ip) return jsonResponse({ success: false, error: "Missing ip parameter" }, 400);
        return await handleAdd(device, ip, env);
      } else if (action === "list") {
        return await handleList(device, env);
      } else if (action === "remove") {
        const ip = url.searchParams.get("ip");
        if (!ip) return jsonResponse({ success: false, error: "Missing ip parameter" }, 400);
        return await handleRemove(device, ip, env);
      } else {
        return htmlPage("Error", "<p>Unknown action</p>", 400);
      }
    } catch (e) {
      return htmlPage("Error", `<p>${e.message}</p>`, 500);
    }
  },
};

const MAX_IPS_PER_DEVICE = 8;
const DEVICE_KEYS_MAP_KEY = "meta:device_keys";

function validateKey(key, env) {
  const devices = [
    { key: env.KEY_DEVICE_1, name: "device1" },
    { key: env.KEY_DEVICE_2, name: "device2" },
  ];
  const match = devices.find((d) => d.key && d.key === key);
  return match ? match.name : null;
}

async function handleSync(request, device, key, env) {
  const cfIp = request.headers.get("CF-Connecting-IP");
  let cfResult = null;

  if (cfIp) {
    cfResult = await addIpToDevice(device, cfIp, env);
  }

  const baseUrl = new URL(request.url).origin;

  const body = `
    <div id="status">
      <h2>Updating whitelist...</h2>
      <div id="cf-ip" class="item">
        <span class="label">Connection IP:</span>
        <span class="value">${cfIp || "unknown"}</span>
        <span class="badge ${cfResult?.changed ? "added" : "ok"}">${cfResult?.changed ? "Added" : "Exists"}</span>
      </div>
      <div id="ipv4" class="item">
        <span class="label">IPv4:</span>
        <span class="value" id="v4">Detecting...</span>
        <span class="badge" id="v4badge"></span>
      </div>
      <div id="ipv6" class="item">
        <span class="label">IPv6:</span>
        <span class="value" id="v6">Detecting...</span>
        <span class="badge" id="v6badge"></span>
      </div>
    </div>
    <div id="summary"></div>
    <div id="entries"></div>
    <script>
      const BASE = "${baseUrl}";
      const KEY = "${key}";
      const CF_IP = "${cfIp || ""}";

      async function probe(url, timeout) {
        const ctrl = new AbortController();
        const t = setTimeout(() => ctrl.abort(), timeout);
        try {
          const r = await fetch(url, { signal: ctrl.signal });
          clearTimeout(t);
          return (await r.text()).trim();
        } catch(e) {
          clearTimeout(t);
          return null;
        }
      }

      async function addIp(ip) {
        if (!ip || ip === CF_IP) return { changed: false, skip: true };
        try {
          const r = await fetch(BASE + "/?key=" + KEY + "&action=add&ip=" + encodeURIComponent(ip));
          return await r.json();
        } catch(e) {
          return { error: e.message };
        }
      }

      function setBadge(id, result) {
        const el = document.getElementById(id);
        if (!el) return;
        if (result.skip) { el.textContent = "Same as connection IP"; el.className = "badge ok"; }
        else if (result.error) { el.textContent = "Failed"; el.className = "badge err"; }
        else if (result.changed) { el.textContent = "Added"; el.className = "badge added"; }
        else { el.textContent = "Exists"; el.className = "badge ok"; }
      }

      async function run() {
        const [v4, v6] = await Promise.all([
          probe("https://api.ipify.org", 5000),
          probe("https://api64.ipify.org", 5000)
        ]);

        document.getElementById("v4").textContent = v4 || "Unavailable";
        document.getElementById("v6").textContent = v6 || "Unavailable";

        let r4 = { skip: true }, r6 = { skip: true };

        if (v4 && v4 !== CF_IP) {
          r4 = await addIp(v4);
        }
        if (v6 && v6.includes(":") && v6 !== CF_IP) {
          r6 = await addIp(v6);
        }

        setBadge("v4badge", v4 ? (v4 === CF_IP ? { skip: true } : r4) : { error: "N/A" });
        setBadge("v6badge", v6 && v6.includes(":") ? (v6 === CF_IP ? { skip: true } : r6) : { error: "N/A" });

        const added = [cfIpAdded(), r4.changed, r6.changed].filter(Boolean).length;
        document.getElementById("summary").innerHTML =
          '<p class="done">Done! ' + (added > 0 ? added + ' IP(s) added' : 'No new IPs') + '</p>';

        try {
          const lr = await fetch(BASE + "/?key=" + KEY + "&action=list");
          const ld = await lr.json();
          if (ld.success) {
            let html = '<h3>Current whitelist (' + ld.count + '/' + ld.max + ')</h3><ul>';
            for (const e of ld.entries) {
              html += '<li>' + e.ip + ' <small>' + e.ago + '</small></li>';
            }
            html += '</ul>';
            document.getElementById("entries").innerHTML = html;
          }
        } catch(e) {}
      }

      function cfIpAdded() { return ${cfResult?.changed ? "true" : "false"}; }

      run();
    </script>`;

  return htmlPage("IP Whitelist - " + device, body);
}

async function handleAdd(device, ip, env) {
  const result = await addIpToDevice(device, ip, env);
  return jsonResponse(result);
}

async function addIpToDevice(device, ip, env) {
  const kvKey = "device:" + device;
  const raw = await env.DEVICE_IPS.get(kvKey, "json");
  let entries = Array.isArray(raw) ? raw : [];

  const existing = entries.find((e) => e.ip === ip);
  if (existing) {
    existing.ts = Math.floor(Date.now() / 1000);
    await env.DEVICE_IPS.put(kvKey, JSON.stringify(entries));
    await syncPolicy(env);
    return { success: true, changed: false, ip, device, entries: entries.length };
  }

  entries.push({ ip, ts: Math.floor(Date.now() / 1000) });

  if (entries.length > MAX_IPS_PER_DEVICE) {
    entries.sort((a, b) => b.ts - a.ts);
    entries = entries.slice(0, MAX_IPS_PER_DEVICE);
  }

  await env.DEVICE_IPS.put(kvKey, JSON.stringify(entries));
  await registerDevice(device, env);
  await syncPolicy(env);

  return { success: true, changed: true, ip, device, entries: entries.length, max: MAX_IPS_PER_DEVICE };
}

async function handleList(device, env) {
  const kvKey = "device:" + device;
  const raw = await env.DEVICE_IPS.get(kvKey, "json");
  const entries = Array.isArray(raw) ? raw : [];

  return jsonResponse({
    success: true,
    device,
    entries: entries.sort((a, b) => b.ts - a.ts).map((e) => ({
      ip: e.ip,
      lastSeen: new Date(e.ts * 1000).toISOString(),
      ago: timeAgo(e.ts),
    })),
    count: entries.length,
    max: MAX_IPS_PER_DEVICE,
  });
}

async function handleRemove(device, ip, env) {
  const kvKey = "device:" + device;
  const raw = await env.DEVICE_IPS.get(kvKey, "json");
  let entries = Array.isArray(raw) ? raw : [];
  const before = entries.length;
  entries = entries.filter((e) => e.ip !== ip);

  if (entries.length === before) {
    return jsonResponse({ success: false, error: "IP not found for " + device }, 404);
  }

  await env.DEVICE_IPS.put(kvKey, JSON.stringify(entries));
  await syncPolicy(env);

  return jsonResponse({ success: true, action: "removed", ip, device, remaining: entries.length });
}

function getFixedEntries(env) {
  const fixedIps = env.FIXED_IPS || "";
  if (!fixedIps.trim()) return [];
  return fixedIps
    .split(",")
    .map((ip) => ip.trim())
    .filter(Boolean)
    .map((ip) => ({ ip: { ip } }));
}

async function syncPolicy(env) {
  const include = [...getFixedEntries(env)];

  const list = await env.DEVICE_IPS.list({ prefix: "device:" });
  for (const kvKey of list.keys) {
    const raw = await env.DEVICE_IPS.get(kvKey.name, "json");
    if (!Array.isArray(raw)) continue;

    for (const entry of raw) {
      const isV6 = entry.ip.includes(":");
      include.push({
        ip: { ip: isV6 ? entry.ip + "/128" : entry.ip + "/32" },
      });
    }
  }

  const policy = await cfFetch(env, "GET", `/access/policies/${env.POLICY_ID}`);
  const result = policy.result;

  await cfFetch(env, "PUT", `/access/policies/${env.POLICY_ID}`, {
    name: result.name,
    decision: result.decision,
    session_duration: result.session_duration || "24h",
    include,
    exclude: result.exclude || [],
    require: result.require || [],
  });
}

async function registerDevice(device, env) {
  const raw = await env.DEVICE_IPS.get(DEVICE_KEYS_MAP_KEY, "json");
  const keys = Array.isArray(raw) ? raw : [];
  if (!keys.includes(device)) {
    keys.push(device);
    await env.DEVICE_IPS.put(DEVICE_KEYS_MAP_KEY, JSON.stringify(keys));
  }
}

async function cfFetch(env, method, path, body) {
  const opts = {
    method,
    headers: {
      Authorization: "Bearer " + env.CF_API_TOKEN,
      "Content-Type": "application/json",
    },
  };
  if (body) opts.body = JSON.stringify(body);

  const resp = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${env.ACCOUNT_ID}${path}`,
    opts
  );
  const data = await resp.json();
  if (!data.success) {
    throw new Error("CF API: " + JSON.stringify(data.errors));
  }
  return data;
}

function timeAgo(ts) {
  const diff = Math.floor(Date.now() / 1000) - ts;
  if (diff < 60) return diff + "s ago";
  if (diff < 3600) return Math.floor(diff / 60) + "m ago";
  if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
  return Math.floor(diff / 86400) + "d ago";
}

function jsonResponse(data, status = 200) {
  return new Response(JSON.stringify(data, null, 2), {
    status,
    headers: { ...corsHeaders(), "Content-Type": "application/json; charset=utf-8" },
  });
}

function htmlPage(title, body, status = 200) {
  const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,system-ui,sans-serif;background:#1a1a2e;color:#eee;padding:20px;min-height:100vh}
h2{color:#0ff;margin-bottom:16px;font-size:18px}
h3{color:#aaa;margin:20px 0 10px;font-size:14px}
.item{display:flex;align-items:center;gap:8px;padding:10px;margin:6px 0;background:#16213e;border-radius:8px;font-size:14px}
.label{color:#888;min-width:60px}
.value{flex:1;word-break:break-all;font-family:monospace;font-size:13px}
.badge{padding:2px 8px;border-radius:4px;font-size:12px;white-space:nowrap}
.badge.added{background:#0a3;color:#fff}
.badge.ok{background:#555;color:#ccc}
.badge.err{background:#a00;color:#fff}
.done{margin-top:16px;padding:12px;background:#0a3;border-radius:8px;text-align:center;font-weight:bold}
ul{list-style:none;padding:0}
li{padding:8px 10px;margin:4px 0;background:#16213e;border-radius:6px;font-family:monospace;font-size:13px}
li small{color:#888;margin-left:8px}
</style>
</head>
<body>
${body}
</body>
</html>`;
  return new Response(html, {
    status,
    headers: { "Content-Type": "text/html; charset=utf-8" },
  });
}

function corsHeaders() {
  return {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "GET, OPTIONS",
  };
}
  1. Save and Deploy

6.4 创建 KV 命名空间并绑定

  1. CF Dashboard → Workers & PagesKV
  2. Create namespace,名称填 DEVICE_IPS
  3. 创建后回到 Worker 页面 → SettingsVariables
  4. KV Namespace BindingsAdd binding
    • Variable name: DEVICE_IPS
    • KV namespace: 选择刚创建的 DEVICE_IPS
  5. Save

6.5 设置环境变量

同一个 Settings → Variables 页面,在 Environment Variables 部分添加以下变量(敏感的打勾 Encrypt):

Variable name Encrypt
CF_API_TOKEN 你的 CF API Token(见下方说明)
ACCOUNT_ID 你的 CF 账号 ID(右上角→邮箱→Account ID)
POLICY_ID Access Policy ID(下一步创建完回来填)
KEY_DEVICE_1 设备1的密钥(如 a1b2c3d4e5f6g7h8
KEY_DEVICE_2 设备2的密钥
FIXED_IPS 固定 IP 段(可选,多个逗号隔开)

Save and Deploy 让变量生效。

API Token 怎么创建:CF Dashboard 右上角 → My ProfileAPI TokensCreate Token。权限选:Workers Scripts Edit、KV Storage Edit、Access: Apps and Policies Edit。

设备密钥怎么生成:浏览器打开 https://www.uuidgenerator.net/ 或任何随机字符串生成器,生成两个 32 位十六进制字符串。

6.6 创建 CF Access Policy

  1. CF Dashboard → Zero TrustAccessPolicies
  2. Create a policy
  3. Policy Name 随便填,比如 手机白名单
  4. Action 选 Bypass(白名单里的直接放行)
  5. Include 规则添加一条,选择 IP ranges,先随便填 1.1.1.1 占位
  6. 创建完后,浏览器地址栏有一串 UUID,那就是 Policy ID。填回上一步的 POLICY_ID 环境变量

6.7 创建 Access Application(和 Policy 关联)

  1. CF Dashboard → Zero TrustAccessApplications
  2. Add an applicationSelf-hosted
  3. Application Name 随便填(比如 飞牛NAS
  4. Subdomain 填你 Tunnel 用的域名,比如 nas.你的域名.com
  5. 在 Policies 页关联上一步创建的 手机白名单 策略
  6. Save

6.8 配自定义域名(重要!)

*.workers.dev 在国内被墙,必须配自己的域名。

  1. Worker 页面 → SettingsDomains & Routes
  2. Add Custom Domain
  3. 输入 wl.你的域名.com
  4. CF 自动配 DNS 和 SSL

6.9 放行 Worker 域名

如果你有 *.你的域名.com 的通配符 Access Application,wl.你的域名.com 会被匹配拦截。

  1. Zero Trust → AccessApplications
  2. 创建新 Application:
    • Name: Worker放行
    • Domain: wl.你的域名.com
    • Policy: BypassEveryone
  3. 把这个 Application 拖到最上面

6.10 生成设备密钥并收藏 URL

把下面两个 URL 加到手机/电脑浏览器收藏夹:

https://wl.你的域名.com/?key=设备1的密钥
https://wl.你的域名.com/?key=设备2的密钥

切网时打开对应收藏夹,5 秒更新白名单。


七、手机端使用

7.1 手动:打开收藏夹

把 URL 加到浏览器收藏夹:

https://wl.你的域名.com/?key=你的设备密钥

连到新 Wi-Fi 或者切了蜂窝后,打开这个收藏夹。页面会自动:

  1. 显示你的 IPv4 和 IPv6 地址
  2. 写入白名单
  3. 更新 CF Access Policy

7.2 进阶:自动触发(切网时自动更新)

iPhone(Shortcuts + Scriptable)

  1. App Store 装 Scriptable
  2. 新建脚本,内容:
const url = "https://wl.你的域名.com/?key=你的密钥";
Safari.open(url);
  1. Shortcuts → Automation → 创建两个自动化:
    • Wi-Fi Is Connected → 运行 Scriptable 脚本
    • Wi-Fi Is Disconnected → 运行 Scriptable 脚本
  2. 两个自动化都 关掉"Ask Before Running"

这样连 Wi-Fi / 断 Wi-Fi(切蜂窝)时自动更新白名单。

Android(HTTP Shortcuts)

  1. 酷安或 Google Play 装 HTTP Shortcuts
  2. 新建 Shortcut → Method: GET → URL 填同步地址
  3. 添加到桌面 Widget,切网时点一下

小米手机记得给 App 开自启动权限。


八、踩坑记录

1. workers.dev 无法访问

*.workers.dev 无法访问。需要配自定义域名。

2. Access 通配符拦截 Worker

如果你有 *.域名.com 的通配符 Application,Worker 子域名会被拦。解决方法:为 Worker 域名单独创建 Bypass 规则(见 6.9)。

3. IPv4 和 IPv6 都要记录

手机 Wi-Fi 下可能只有 IPv4,切蜂窝可能只有 IPv6。sync 页面同时探测两个地址并自动写入。

4. description 字段导致 CF API 报错

CF Access Policy 的 include 条目里不能带 description 字段。Node 代码已经排除了这个字段,直接用没问题。

5. CF API Token 权限要够

必须包含三个权限:Workers Scripts Edit、KV Storage Edit、Access: Apps and Policies Edit。缺少任何一个都会报错。


九、适用场景

  • 飞牛 NAS 远程访问
  • Home Assistant、Alist、Bitwarden 等自用服务
  • 任何没有公网 IP 的内网服务
  • ARM 低配 NAS(NAS 零负载)

不适合:需要公开访问的服务(博客、论坛)。


十、为什么不装本地 WAF

对比 本地 WAF(雷池/SafeLine) CF Access IP 白名单
拦截位置 NAS 本地(流量已到达) CF 边缘(到不了你)
内存占用 300-500MB 0MB
配置难度 规则多、易误报 只管 IP
攻击面 NAS 端**露 完全隐藏

对于 ARM 低配 NAS,这套方案是最省资源也最安全的选择。


十一、局限

  • 每台新设备要生成一个密钥(openssl rand -hex 16
  • 切网后需要触发一次更新(手动收藏夹或自动 Shortcuts)
  • Workers 免费 10 万次/天(手动触发完全够用)
  • CF Access Policy include 数组上限 1000 条(每设备 8 条,远超日常需求)

项目地址

https://github.com/mydelren/cloudflare-worker-ip-whitelist

给个 Star 支持一下,有问题提 Issue。欢迎回帖交流。

收藏
送赞 1
分享
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则