本篇文章描述了我如何给 Caddy 限制访问来源,以及把访问日志送到 Loki,配个 Grafana 仪表板。过家家就得过足咯!谁来也得登记!

前情提要

上一篇把 OpenBao 装好,通过 Caddy 和 Cloudflare 提供访问。

本文接着已有的 Docker Compose、Caddy 和 OpenBao 环境操作,默认您会改 Caddyfile、查看容器日志。下文域名 openbao.miyunda.com、实例名 app-01 和客户端 IP 都是虚构的,仅为示例用途,不要去攻击;Loki 地址、租户编号及 Token (API访问凭据) 也需要结合自己的环境填写。

C 谁啊?

旧配置里有这么一段:

(cf_only) {
    @no_cf {
        not header Cf-Ray *
    }
    handle @no_cf {
        abort
    }
}

我就说你们看都没看,点赞就走。其实这个片段站点都没有导入,白白浪费资源。话又说回来了,即使导入了也不好使——直接访问源站的人可以自己伪造 Cf-Ray,HTTP Header 分分钟搓出来几百个。

所以这次按实际连接来源判断,同时把真实客户端地址还原出来。一个连接过来的时候里面有这样两个字段令我感兴趣:

字段 是谁 用途
remote_ip 直接连接 Caddy 的对端地址,正常回源时是 Cloudflare 节点 判断来源是否在 Cloudflare 网段内
client_ip 从可信代理提供的头部中解析出的客户端地址 看谁访问了服务、查日志

限制来源用 remote_ip;只有来源可信,才读取它给的 CF-Connecting-IP。Caddy 的 官方文档和 remote_ip 匹配器说的也是这两个层次。这里没有另加 PROXY protocol 或其他前置代理。

你这网段保真吗

Cloudflare 的网段不算少,纯大力的话可以在 Caddyfile 里复制两遍,就是太笨,以后更新时很容易漏一边。我把它放进环境文件,让可信代理和来源匹配共用。按说可以自动获取并下载这个列表,俺寻思这个应该也不怎么经常变,就手动放了一份在文件夹里。

~/docker/caddy/
          ├── compose.yaml
          └── conf/
              ├── Caddyfile
              └── cf-ips.env

完整清单从 Cloudflare 官方取:IPv4、IPv6。两份都要,合并成一行,以空格分隔,写进 conf/cf-ips.env:

# 占位示意:必须用完整的官方 IPv4/IPv6 CIDR 列表替换下面的值
CF_IPS="<完整网段列表,以空格分隔>"

以上列表特别长长长。

假如列表没填全,正常的 Cloudflare 回源也会被挡在外面;以后还得定期与官方清单同步,我是打算什么时候想起来就更新下——反正也没有别人来访问。

在原来的 Compose 服务里加上 env_file,其余镜像、端口、挂载和网络保留:

services:
  caddy:
    # 合并到已有服务配置中
    env_file:
      - ./conf/cf-ips.env

然后改 Caddyfile。全局块放文件开头,已有全局块就合并进去:

{
    servers {
        trusted_proxies static {$CF_IPS}
        client_ip_headers CF-Connecting-IP
    }
}

openbao.miyunda.com {
    @cf remote_ip {$CF_IPS}

    handle @cf {
        reverse_proxy openbao:8200
    }
    handle {
        respond "Forbidden" 403
    }

    log {
        output file /var/log/caddy/openbao_access.log {
            roll_keep 10
            roll_keep_for 168h
        }
    }
}

http:// {
    abort
}

:443 {
    abort
}

最后两个拒绝野鸡访问的还留着;如果您的 Caddy 还代理其他网站,要合并已有站点和各自的来源策略,自己看着办。

填好列表后,先校验再重建:

cd ~/docker/caddy
docker compose config --quiet
docker compose run --rm --no-deps caddy \
  caddy validate --config /etc/caddy/Caddyfile
docker compose up -d --force-recreate caddy

这里有个小坑:{$CF_IPS} 读的是 Caddy 进程的环境。宿主机文件改了,正在运行的容器不会凭空得到新变量,你拿前朝的剑斩本朝的官,因此只执行 caddy reload 不够。重建 Caddy 会有短暂中断。

这套规则验证的是来源属于 Cloudflare 网段。

让我测测

找一台独立客户端,模拟一下。以下 IP 变量填源站的实际公网 IP,域名也换成自己的:

curl -vI --noproxy '*' \
  --resolve "openbao.miyunda.com:443:${ORIGIN_IP:?请先设置源站公网IP}" \
  https://openbao.miyunda.com/

这次记录到的关键响应:

HTTP/2 403
server: Caddy
content-length: 9

TLS 握手里看到的是源站 Caddy 绑定的证书。请求到了 Caddy,随后被 HTTP 来源规则拒绝,以上都看过,与本人期望的相符。curl 文档也说明了 * 会禁用其显式代理路径;如果环境里还有透明代理或网络隧道,仍需结合连接地址与日志判断。

若您的源站使用私有 CA,应通过 --cacert 提供信任链。

这里拦的是 HTTP 请求,源站的 TCP/TLS 连接仍能建立

403 怎么没进日志?

刚开始 tail 没看到,我还以为 Caddy 只记成功请求。其实 level=info 跟 HTTP 200 不是一回事,403 也可以出现在 info 级别的访问日志中。

给请求一个独特路径,再查一次:

curl -I --noproxy '*' \
  --resolve "openbao.miyunda.com:443:${ORIGIN_IP:?请先设置源站公网IP}" \
  https://openbao.miyunda.com/test-cf-block-20261028
sudo rg 'test-cf-block-20261028' /srv/caddy/log/openbao_access.log

curl 在测试客户端执行,rg 在 Caddy 宿主机执行;没有 rg 用 grep 代替。这次原始日志中有如下字段,下面删去了无关内容,并替换了实际 IP:

{
  "level": "info",
  "request": {
    "remote_ip": "198.51.100.20",
    "client_ip": "198.51.100.20",
    "uri": "/test-cf-block-20261028"
  },
  "status": 403
}

直连时两个 IP 相同。正常从 Cloudflare 访问,则看对端是否为 Cloudflare 地址,以及还原后的客户端 IP 是否符合预期。平时可以这样挑几个字段看:

sudo tail -n 30 /srv/caddy/log/openbao_access.log | jq -c '{
  remote_ip: .request.remote_ip,
  client_ip: .request.client_ip,
  method: .request.method,
  uri: .request.uri,
  status: .status
}'

不是每次都出现 307。根路径可能跳到 /ui/,网页资源正常返回 200,一样可以检查地址有没有还原。

Cloudflare 侧确认配合使用 Full (strict)——默认就是,让它验证源站(即我们的 Caddy )证书;动态认证/API 的缓存需要绕过,/v1/* 慎重加交互式 Challenge,CLI 和脚本就废了。证书续期的 ACME 路径同样要保留。相关模式说明见 Cloudflare 文档。

DM Loki

访问日志不可能每次都要登到服务器上看,接下来请出 Loki。Loki 与 OpenBao 无关,就是读读 Caddy 的日志。

---
config:
  theme: base
  themeVariables:
    background: "#faf4ed"
    primaryColor: "#fffaf3"
    primaryTextColor: "#464261"
    primaryBorderColor: "#286983"
    lineColor: "#286983"
    textColor: "#464261"
    edgeLabelBackground: "#faf4ed"
---
flowchart TB
    A["客户端"] --> B["Cloudflare"]
    B --> C["Caddy"]
    C --> D["OpenBao :8200"]
    C --> E["访问日志记录为本地 JSON"]
    E -- "只读采集" --> F["Grafana Alloy"]
    F -- "推送" --> G["Grafana Cloud Loki"]
    G --> H["Grafana 查询 / 仪表板"]
    classDef service fill:#fffaf3,stroke:#286983,color:#464261;
    classDef logs fill:#f2e9e1,stroke:#907aa9,color:#464261;
    class A,B,C,D service;
    class E,F,G,H logs;

日志采集用 Grafana Alloy,仍然是 Docker 容器,独立,只读挂载日志目录,不开放端口,也不走 Docker Socket。之前的 叫 Promtail,Promtail 已死,有事烧纸。见 Grafana 的说明。

服务器先用免费的 Grafana Cloud,等以后换自建 Loki,到时候换下推送地址和访问凭据即可。

地址、账号和 Token

文件夹准备成这样:

~/docker/alloy/
          ├── compose.yaml
          ├── config.alloy
          ├── .env
          └── data/
mkdir -p ~/docker/alloy/data
cd ~/docker/alloy

在 Grafana Cloud 取 Loki 的推送地址和租户编号,给 Alloy 建一个带 logs:write 权限的专用 Cloud Access Policy,和 Token (访问凭据)。

在本机编辑 .env,填好下面三项;这里不展示实际值:

# Loki 完整写入 URL,路径应包含 /loki/api/v1/push
LOKI_URL=
# Grafana Cloud 提供的 Loki 租户编号,不是 Grafana 那边的
LOKI_USERNAME=
# 专用的 logs:write Token
LOKI_PASSWORD=
chmod 600 .env

Alloy 的两个文件

compose.yaml:

services:
  alloy:
    image: grafana/alloy:v1.21.2
    container_name: alloy
    restart: unless-stopped
    command:
      - run
      - --storage.path=/var/lib/alloy/data
      - /etc/alloy/config.alloy
    env_file:
      - .env
    volumes:
      - ./config.alloy:/etc/alloy/config.alloy:ro
      - ./data:/var/lib/alloy/data
      - /srv/caddy/log:/var/log/caddy:ro

这次测试记录用的是 v1.21.2;日志目录也要允许容器用户读取。

.env 要通过 env_file 明确传进容器。Compose 会自动读取项目的 .env 做插值。Docker 官方文档

config.alloy:

local.file_match "caddy" {
  path_targets = [
    {
      "__path__" = "/var/log/caddy/openbao_access.log",
      "job"      = "caddy",
      "service"  = "openbao",
      "instance" = "app-01",
    },
  ]
}

loki.source.file "caddy" {
  targets       = local.file_match.caddy.targets
  forward_to    = [loki.write.default.receiver]
  tail_from_end = true
}

loki.write "default" {
  endpoint {
    url = sys.env("LOKI_URL")

    basic_auth {
      username = sys.env("LOKI_USERNAME")
      password = sys.env("LOKI_PASSWORD")
    }
  }
}

job ——谁的日志,service ——哪个服务,instance ——多个的时候区分实例。

tail_from_end = true 有个前提:没有保存过该文件的读取位置时,从末尾开始。已有位置则按位置继续。它不会自动补之前的日志,loki.source.file 文档解释了这个行为。

docker compose config --quiet
docker compose up -d
docker compose logs --tail=50 alloy

试试

新发一条带独特路径的请求:

curl -sS -o /dev/null https://openbao.miyunda.com/test-loki-20261028

然后在 Grafana Explore 选择自己的 Loki 数据源,查:

{job="caddy",service="openbao"} |= "test-loki-20261028"

这次确实查到了。至此访问请求、Caddy 文件、Alloy 和云端 Loki 连了起来。

几个简单的查询例子

先看所有 OpenBao 访问记录:

{job="caddy",service="openbao"}

加 | json 后就可以按字段筛选。例如查看 403:

{job="caddy",service="openbao"} | json | status="403"

只看 API 请求,再把一长串 JSON 排成容易读的样子:

{job="caddy",service="openbao"}
| json
| request_uri=~"/v1/.*"
| line_format "{{.request_client_ip}} | {{.request_method}} {{.request_uri}} | HTTP {{.status}}"

这里 request_client_ip 对应 JSON 里的 request.client_ip。字段提取和排版的语法见 LogQL 文档。

最近一小时的请求总量:

sum(count_over_time({job="caddy",service="openbao"}[1h]))

按状态码统计,或者看访问最多的 IP:

sum by (status) (
  count_over_time(
    {job="caddy",service="openbao"} | json [1h]
  )
)
topk(10,
  sum by (request_client_ip) (
    count_over_time(
      {job="caddy",service="openbao"} | json [1h]
    )
  )
)

403可能是 Caddy 挡下的直连,也可能是 OpenBao 自己返回的权限错误。只看403,不能知道是谁拒绝的。以后要精确统计源站拦截,可以给 Caddy 的拒绝日志加一个可查询的标记。

EM 仪表板没找到合身的

我先找了两份别人的公开模板:

模板 不适合我
Caddy Monitoring,22806 同时要 Prometheus 和 Loki;我现在只有日志,性能那块是空的,Loki 部分主要是基础表格与原始日志
Loki:AccessLogs,19782 显示无数据,和我这套标签、日志格式颗粒度没对齐

为了填满一个模板再部署 Prometheus 有点烧心(我这个服务器性能绝对没问题——因为没有用户)。于是自己动手做了一份只用 Loki 的仪表板,再加上服务和实例选择,给以后别的用 Caddy 的服务留出空间。

八个面板分别是请求总量、HTTP 403、5xx、请求趋势、状态码趋势、客户端 IP 排名、403 来源 IP 排名,以及最近的访问记录。

Service 和 Instance 两个变量放在顶部,查询使用:

{job="caddy",service=~"${service:regex}",instance=~"${instance:regex}"}

以后别的服务也按同一个约定填标签:

job service instance 状态
caddy openbao app-01 本次已接入
caddy app1 app-02 未来示例
caddy app2 app-03 未来示例

一台 Caddy 代理多个服务时,Alloy 需要按对应日志文件设置 service,不能让所有文件都叫 OpenBao。

Caddy Security Dashboard

还有点修修补补:请求趋势的图例显示 {},可以把 Display name 改为 Requests;Top IP 表格多了 Time 列,通过 Transformations 隐藏,留下 Client IP / Requests 就清楚多了。

将来换成自建 Loki

今年先用云服务,打算明年换自建。保留 JSON 格式和 job/service/instance 标签,查询俺寻思可以沿用,到时候改动应该不多:

  1. 在 .env 更新完整的 /loki/api/v1/push 地址和凭据。若自建端通过可信私网提供无认证写入接口,需同时调整 Alloy 的认证块,不能只把密码留空。
  2. 执行 docker compose up -d --force-recreate alloy,让新环境变量进容器。
  3. Grafana 添加新的 Loki 数据源,将仪表板关联过去。
  4. 核对 TLS、权限和保留策略;历史日志要不要了。

这个我还没试过,应该差不多,参考我之前的文章 用自建Grafana替换官方云服务。

F 小结

这次正常的 Cloudflare 回源能访问 OpenBao,独立客户端直连源站得到 Caddy 的 403,记录也能从本地文件一路查到 Grafana。仪表板先有得看,图例和表格等数据多了请 AI 修。

这篇文章不止适用于 OpenBao 任何公网 Web 服务都可以按照这个思路发布。


下一步探讨创建 OpenBao 日常用户而不是一直用 root。