Sub-Store Azure + Cloudflare Access 部署实践

在 Azure VM 上部署 Sub-Store 后端,通过 Caddy 反向代理 + Cloudflare DNS 代理 + Cloudflare Access 实现安全访问控制,解决 Basic Auth 认证循环弹窗问题。

#type / howto #status / growing #tech / ops / proxy #tech / ops / cloudflare #tech / ops / caddy

[!info] related notes

Sub-Store Azure + Cloudflare Access 部署实践

目标

在单台 Azure VM(Debian 12)上部署 Sub-Store 订阅管理服务,实现管理后台安全访问、订阅链接公开拉取,同时保证暴露面最小化。

架构设计

用户浏览器


Cloudflare Edge(DNS 代理 + Access 认证)

    ▼  HTTPS 回源(仅 Cloudflare IP 可达)
Azure VM (Debian 12)
    ├── Caddy(反代 + TLS)
    │   ├── admin.bakersean.top
    │   │   ├── / → reverse_proxy → sub-store.vercel.app(前端)
    │   │   └── /<PREFIX>/* → reverse_proxy → 127.0.0.1:3000(后端 API)
    │   ├── substore.bakersean.top
    │   │   └── /share/* → reverse_proxy → 127.0.0.1:3000(公开分享)
    │   └── nezha.bakersean.top → reverse_proxy → 127.0.0.1:8008
    └── Sub-Store 后端(Node.js,仅监听 127.0.0.1:3000)

核心原则:所有服务只监听 localhost,对外仅暴露 Caddy,通过 UFW 限制只有 Cloudflare IP 可以回源。

前置条件

  • Azure VM(Debian 12),仅开放 SSH(22/tcp)
  • 域名已托管到 Cloudflare,DNS 代理已开启(橙色云朵)
  • Azure NSG 已放行 22/80/443(TCP)

部署步骤

1. 安装依赖

# Node.js 22(NodeSource 官方脚本)
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs

# Caddy(Debian 官方源)
sudo apt-get install -y caddy

# 防火墙 + 防爆破
sudo apt-get install -y ufw fail2ban

2. 构建 Sub-Store 后端

# 创建独立运行用户
sudo useradd --system --create-home --home-dir /var/lib/sub-store \
  --shell /usr/sbin/nologin substore

sudo install -d -o substore -g substore /opt/sub-store /var/lib/sub-store /etc/sub-store

# 拉取源码并构建
sudo git clone --depth=1 https://github.com/sub-store-org/Sub-Store /opt/sub-store/repo
cd /opt/sub-store/repo/backend
sudo npm install -g pnpm@11
sudo pnpm install --frozen-lockfile
sudo pnpm bundle:esbuild
sudo chown -R substore:substore /opt/sub-store /var/lib/sub-store /etc/sub-store

3. 生成随机前缀和配置

PREFIX=$(openssl rand -hex 12)

sudo tee /etc/sub-store/sub-store.env >/dev/null <<EOF
SUB_STORE_BACKEND_API_HOST=127.0.0.1
SUB_STORE_BACKEND_API_PORT=3000
SUB_STORE_BACKEND_PREFIX=1
SUB_STORE_FRONTEND_BACKEND_PATH=/$PREFIX
SUB_STORE_DATA_BASE_PATH=/var/lib/sub-store
SUB_STORE_CORS_ALLOWED_ORIGINS=https://admin.bakersean.top,https://sub-store.vercel.app
EOF

sudo chown root:substore /etc/sub-store/sub-store.env
sudo chmod 640 /etc/sub-store/sub-store.env

[!warning] 安全要点 SUB_STORE_CORS_ALLOWED_ORIGINS 一定不要保留默认值 *,否则任何站点都能通过浏览器读取你的 API。

4. 配置 systemd 服务

[Unit]
Description=Sub-Store backend
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=substore
Group=substore
WorkingDirectory=/opt/sub-store/repo/backend
EnvironmentFile=/etc/sub-store/sub-store.env
ExecStart=/usr/bin/node /opt/sub-store/repo/backend/sub-store.min.js
Restart=always
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/var/lib/sub-store

[Install]
WantedBy=multi-user.target

5. 配置 Caddy

admin.bakersean.top {
    encode zstd gzip

    # Cloudflare Access JWT 校验(非 Access 认证的请求直接拒绝)
    @no-auth {
        not header CF-Access-JWT-Assertion *
    }
    respond @no-auth "Forbidden" 403

    # 后端 API(随机前缀路径)
    @backend path /<PREFIX>/*
    handle @backend {
        reverse_proxy 127.0.0.1:3000
    }

    # 根路径重定向,告知前端后端地址
    @needs_api {
        path /
        not query api=*
    }
    redir @needs_api /?api=https://admin.bakersean.top/<PREFIX> 302

    # 反向代理官方前端
    reverse_proxy https://sub-store.vercel.app {
        header_up Host sub-store.vercel.app
    }
}

substore.bakersean.top {
    encode zstd gzip
    handle /share/* {
        uri replace /share /<PREFIX>/share
        reverse_proxy 127.0.0.1:3000
    }
    respond "Not Found" 404
}

6. UFW 防火墙:只允许 Cloudflare IP 回源

# Cloudflare IPv4 回源地址段
CF_IPS=(
  173.245.48.0/20  103.21.244.0/22  103.22.200.0/22
  103.31.4.0/22    141.101.64.0/18  108.162.192.0/18
  190.93.240.0/20  188.114.96.0/20  197.234.240.0/22
  198.41.128.0/17  162.158.0.0/15   104.16.0.0/13
  104.24.0.0/14    172.64.0.0/13    131.0.72.0/22
)

for ip in "${CF_IPS[@]}"; do
  sudo ufw allow from "$ip" to any port 80 proto tcp
  sudo ufw allow from "$ip" to any port 443 proto tcp
done

# 删除旧的"Anywhere"规则(用 yes 管道自动确认)
while true; do
  RULE=$(sudo ufw status numbered | grep -E '\b(80|443)/tcp\b.*Anywhere' | head -1)
  [ -z "$RULE" ] && break
  NUM=$(echo "$RULE" | grep -oP '^\[\s*\K\d+')
  [ -n "$NUM" ] && yes | sudo ufw delete "$NUM" || break
done

sudo ufw allow 22/tcp
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw --force enable

[!tip] 为什么必须限制 Cloudflare IP 如果 80/443 对全网开放,任何人都能绕过 Cloudflare Access 直连服务器。UFW 限制回源 IP 是 Cloudflare Access 方案的”最后一公里”保障。

7. 配置 Cloudflare Access

Cloudflare Zero Trust 面板中创建两个 Self-hosted Application:

应用 1:管理前端(邮箱认证)

字段
Application namesubstore
Public hostnameadmin.bakersean.top(无路径)
Policy ActionAllow
Include 规则Emails → 你的邮箱地址
Session Duration24 hours 或 1 week

应用 2:后端 API(Bypass 放行)

字段
Application nameadmin
Public hostnameadmin.bakersean.top + Path: /<PREFIX>/*
Policy ActionBypass
Include 规则Everyone

[!important] 为什么需要两个应用 Cloudflare Access 的 Policy Include 规则中没有 Path 选项,路径匹配是在 Public hostname 层级定义的。因此需要为 API 路径单独创建一个应用,设置 Bypass 策略,否则后端 API 请求也会被 Access 拦截(表现为订阅导入返回登录页 HTML)。

调试记录:Basic Auth 认证循环弹窗

现象

访问 admin.bakersean.top 时,浏览器弹出原生 HTTP Basic Auth 对话框,输入用户名密码后立刻再次弹出,反复循环无法进入。

原因分析

  1. Caddy 配置了 basicauth *,所有路径(包括前端页面和后端 API)都要求 Basic Auth
  2. 用户认证通过后,Sub-Store 前端 JS 加载,立刻向后端 API 发起 fetch/XHR 请求
  3. 关键fetch 请求默认不会自动携带 Basic Auth 的 Authorization 头(与浏览器原生页面请求不同)
  4. 后端 API 返回 401 → 浏览器再次弹出认证对话框 → 循环

根本原因:HTTP Basic Auth 是为传统”整页请求”设计的,不适合保护前后端分离架构中 JS 动态调用的 API。

解决方案

移除 Caddy 的 basicauth,改用 Cloudflare Access 保护管理面。Cloudflare Access 基于 cookie/session 工作,浏览器原生支持,不存在 JS API 调用丢凭据的问题。

常见问题

Q: Cloudflare Access 的 Tunnel 步骤要跳过吗?

是的。如果使用 Cloudflare DNS 代理回源(而非 cloudflared tunnel),在 Access 应用创建向导中点击 “Skip this for now” 跳过 Tunnel 分配步骤。

Q: Bypass 策略的 Include 里没有 Path 选项怎么办?

Cloudflare Access Policy 的 Include 规则不支持路径匹配。路径区分在 Application 的 Public hostname 层级配置——为需要 Bypass 的路径单独创建一个 Application。

Q: 订阅导入返回 Cloudflare 登录页 HTML?

说明后端 API 路径也被 Cloudflare Access 保护了。检查是否为 API 路径创建了 Bypass Application,以及该 Application 的 Public hostname 路径是否正确匹配 /<PREFIX>/*

Q: Caddy 版本太低没有 jwt 指令怎么办?

Caddy 2.8+ 才内置 jwt 指令。低版本可以用 @no-auth { not header CF-Access-JWT-Assertion * } 检查头的存在性,配合 UFW 限制 Cloudflare IP 回源来实现等效保护。

验证清单

  • curl -I https://admin.bakersean.top 返回 Cloudflare Access 登录页(未认证状态)
  • 通过邮箱验证后正常进入 Sub-Store 管理界面
  • curl https://admin.bakersean.top/<PREFIX>/api/file/all-nodes-all-ways 直接返回订阅数据(Bypass 生效)
  • curl -H "Host: admin.bakersean.top" http://<SERVER_IP> 返回 403(UFW 拒绝非 Cloudflare IP)
  • sudo ufw status 确认 80/443 仅允许 Cloudflare IP 段
  • sudo systemctl status sub-store caddy 确认服务正常运行
创建于 2026/6/18 更新于 2026/6/18