# 零度 · 全部文章

共 8 篇

# 给一台小服务器套上 Cloudflare：免费版的边界在哪

> 2026-11-02 · 建站

很多人说的"套个 Cloudflare"，其实只是把橙色云朵点亮。它确实能挡掉一部分东西，但免费版的额度比想象中紧，而且最容易出事的地方通常不在 Cloudflare，而在源站。

这篇按我自己那台机器的顺序写：先讲清楚防护发生在哪一层，再说免费版到底给了什么，最后是源站必须配合的两件事。

## 一、防护发生在哪一层

请求进到你的服务器之前，会先落在 Cloudflare 的边缘节点。能被拦在那里的部分越多，源站就越轻松。

| 层 | 在做什么 | 免费版 |
|---|---|---|
| DNS | 对外只暴露 Cloudflare 的地址 | 有 |
| 边缘 DDoS | L3/L4/L7 清洗，不计费 | 有 |
| WAF | 托管规则 + 自定义规则 | 自定义 5 条 |
| 速率限制 | 按 IP 和路径限速 | 只有 1 条 |
| 源站 | 限速、只放行回源、纯静态 | 自己配 |

最后一行经常被忽略。边缘再强，只要源站 IP 能被直接访问，前面配的东西都可以绕过去。

## 二、迁 DNS 之前先备份

Cloudflare 的 CDN 能力建立在它接管解析上。换 NS 是整体换掉解析服务商，漏一条记录就掉一个服务。先把现有记录导出来：

```
# 按你自己的子域列表补全
for n in @ www mail blog; do
  echo "== $n"
  dig +short A "$n.example.com"
done | tee dns-backup.txt
```

特别留意三类：MX 和 TXT（邮件验证，最容易漏）、泛解析 `*`（Cloudflare 不会自动建）、以及那些"不是 80/443 用途"的子域。

:::注意
橙色云朵不是全开就好。只有 HTTP/HTTPS 网站该开代理；SSH、面板、代理节点这类必须保持灰云，否则会直接连不上。
:::

## 三、回源模式选 Full (strict)

四种模式的区别值得记住，因为它直接决定源站流量在公网上是不是明文：

```
Flexible      Cloudflare → 源站 走 HTTP，明文回源，最危险
Full          走 HTTPS，但不验证证书，可被中间人
Full(strict)  走 HTTPS 且验证证书 —— 选这个
```

Strict 的前提是源站有一张被信任的证书。可以用 Cloudflare 的 Origin CA（15 年、不写入证书透明度日志），也可以用 Let's Encrypt（通用，但签发记录会公开）。

## 四、让 nginx 认得真实访客

开了代理之后，`$remote_addr` 全是 Cloudflare 的机房地址。日志和限速都对着同一批 IP 生效，等于失效。

```
set_real_ip_from 173.245.48.0/20;
# … 其余 14 段 IPv4 和 7 段 IPv6，用脚本生成，别手抄
real_ip_header    CF-Connecting-IP;
real_ip_recursive on;
```

只有列在 `set_real_ip_from` 里的网段发来的这个头才被采信，所以伪造它没有用。

## 五、源站只接受回源

这一步才是真正"藏源站"。做法是判断连接的对端地址是不是 Cloudflare，不是就拒绝。注意别拿 `CF-Connecting-IP` 判断——那个头可以伪造。

```
set $allow_direct 0;
set $deny_direct 0;
if ($is_cf_edge = "0")   { set $deny_direct 1; }
if ($allow_direct = "1") { set $deny_direct 0; }
if ($deny_direct = "1")  { return 403; }
```

之所以拆成三条，是因为 nginx 的 `if` 不支持变量拼接，`if ($a$b = "00")` 会报 unknown variable。

:::坑
切到 0 之前务必确认 Cloudflare 链路是通的。改完如果打不开，本地和 CDN 都被挡，只能 SSH 上去改回来。
:::

## 六、验证

配置完跑这几条，缺一条都说明没生效：

```
# 1. 是否经过边缘
curl -sI https://blog.example.com | grep -i cf-ray

# 2. 直连源站是否被拒（期望 403）
curl -s -o /dev/null -w '%{http_code}\n' \
  --resolve blog.example.com:443:源站IP https://blog.example.com/

# 3. 限速是否生效（20 并发）
seq 1 60 | xargs -P20 -I{} curl -s -o /dev/null -w '%{http_code}\n' \
  https://blog.example.com/ | sort | uniq -c
```

第三条会同时看到 200 和 429，那就对了。

## 七、真被打的时候

顺序大概是这样：先看 Security Events 里哪条规则在拦、哪条没拦住；然后把 Security Level 提到 High；有明确特征（固定 UA 或路径）就加一条自定义规则，这比封 IP 有效得多；实在扛不住再开 Under Attack，那里是全站 JS 挑战，SEO 会受影响，攻击一停要记得关。

别急着换源站 IP。只要藏源站还在生效，清洗都在边缘完成，换 IP 只是暴露新线索。

:::边界
免费版挡不住"直接打 IP"的流量。如果同机的其他域名还留着灰云 A 记录，任何人都能 dig 出源站地址，然后绕开这一整套。真要解决，得让源站不再暴露公网端口。
:::

# 一条 rsync 管好全站图片

> 2026-11-01 · 工具

截图和配图是静态站最容易失控的地方。我原来的目录里躺着 200 多张 PNG，平均 800KB，一张首页要吃掉 6MB。

现在往 `inbox/` 里丢原图，跑一条命令，输出就是压缩好的 WebP，并按年月归档。

## 脚本

```
#!/usr/bin/env bash
# images.sh —— 原图丢 inbox/，跑一次生成 WebP 到 assets/img/
set -euo pipefail

SRC="${1:-inbox}"
DST="${2:-assets/img}"
mkdir -p "$DST"

find "$SRC" -maxdepth 1 -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' \) |
while read -r f; do
  base="$(basename "${f%.*}")"
  # 长边压到 1600，质量 82，体积和观感基本是平衡点
  cwebp -quiet -q 82 -resize 1600 0 "$f" -o "$DST/$base.webp"
  echo "✓ $base.webp  $(du -h "$DST/$base.webp" | cut -f1)"
done

# 顺手统计一下
printf '共 %s 个文件，合计 %s\n' \
  "$(ls -1 "$DST"/*.webp 2>/dev/null | wc -l)" \
  "$(du -sh "$DST" | cut -f1)"
```

需要 `cwebp`：Debian 上是 `apt install webp`。

## 为什么是 1600 / 82

我试过几组参数：

- 原图 2400 宽、质量 90：一张 400KB，肉眼和 1600/82 几乎没区别
- 1600 宽、质量 82：一张 90–140KB
- 1200 宽、质量 78：手机上看没问题，桌面大屏开始糊

博客正文最大显示宽度 672px，两倍屏就是 1344。1600 留了点余量给封面图。

## 保留原图，但别上传

原图留在 `assets/img-src/`（不进部署目录），需要重新生成或者换尺寸时有源可依。`rsync --delete` 只同步 `assets/img/`，源图永远不会上服务器。

## 一个副作用

换成 WebP 之后，Cloudflare 的缓存命中率明显上去了——同一个页面第二次访问基本全是 `cf-cache-status: HIT`。理由很简单：资源变小了，边缘节点更容易把它留在缓存里，回源次数直接降下来。

:::注意
WebP 在 2020 年之后的浏览器全支持，不用再准备 fallback。真遇到老设备，图片不显示比装一堆 `<picture>` 更少见。
:::

# 日志里全是机房 IP：realip 到底怎么还原

> 2026-10-28 · 网络

挂上 CDN 的第二天我发现两件事：访问日志里全是同一批机房 IP，nginx 的限速也变成了随机伤人——明明是别人打你，偶尔被封的却是正常用户。

原因就一个：`$remote_addr` 已经不是访客的地址了。

## 问题长什么样

```
162.158.88.31 - - [28/Oct/2026:10:12:03 +0800] "GET / HTTP/2.0" 200
162.158.88.31 - - [28/Oct/2026:10:12:04 +0800] "GET /assets/css/style.css" 200
172.71.150.6  - - [28/Oct/2026:10:12:05 +0800] "GET /post.html" 200
```

这些都是 CDN 的回源节点。日志看不出谁在访问，`limit_req` 也会把它们当成同一个来源。

## 修法：让 nginx 信任 CDN 网段

```
# /etc/nginx/snippets/cf-realip.conf
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# … 其余网段用脚本生成，别手抄：CDN 会新增网段
real_ip_header    CF-Connecting-IP;
real_ip_recursive on;
```

然后在 server 块里 `include` 它。

三个关键点：

- **`set_real_ip_from` 是白名单**。只有这些网段发来的 `CF-Connecting-IP` 才会被采信，所以别人伪造这个头没有用。
- **`real_ip_recursive on`** 配合单一 header 才好用；如果 CDN 传的是 `X-Forwarded-For` 链，它决定了从右往左信任到哪一跳。
- **段列表要能自动更新**。我写了个脚本拉官方列表生成 snippet，失败时用内置列表兜底，这样不会因为网络问题把 nginx 配坏。

## 最容易踩的三个坑

**一、zone 定义了但没生效。** `limit_req_zone` 必须写在 http 级。放 server 里 `nginx -t` 直接报错，或者你以为是全局的其实只对某个站点生效。

**二、realip 没 include 进这个 server。** 症状很隐蔽：日志还是机房 IP，但限速"看起来在工作"。实际是所有访客共用一个桶。

**三、所有访客一起被 429。** 这是 realip 失败最危险的表现。20 个人同时用你的站，看起来像被打了。判断方法：临时在 server 里加 `add_header X-Debug-IP $remote_addr always;`，看返回的头是不是自己的出口 IP。

## 验证

```
# 用手机 4G 打开站点，然后本机看日志
tail -f /var/log/nginx/blog.access.log
# 应该出现你手机的出口 IP，不再是 162.158.x / 172.71.x
```

反过来再验一次"直连会不会被误判"：从非 CDN 网段直接请求源站 IP，看 `$remote_addr` 是不是对方真实地址、有没有被 `set_real_ip_from` 之外的值改写。

:::注意
realip 生效之后，你的限速、封禁、日志统计才第一次变准。所以它是所有"按 IP 做策略"的前提，不是可选项。
:::

# 我把博客从 WordPress 换成了纯静态

> 2026-10-21 · 建站

换的原因很实际：一台 2G 内存的小机器，WordPress 开着插件、MySQL 常驻，平时就吃掉一半内存。被扫一次后台，PHP-FPM 直接打满，站点跟着 502。

现在是一堆 HTML 文件。同样的机器，同样的流量，内存常年压在 400M 以内。

## 攻击面差在哪

| 项 | WordPress | 纯静态 |
|---|---|---|
| 数据库 | MySQL，SQL 注入面 | 无 |
| 后台 | `/wp-login.php` 可爆破 | 无 |
| 插件 | 漏洞主要入口 | 无 |
| 单请求成本 | 解释 PHP + 查库 | 读文件，微秒级 |
| 被 CC 时 | PHP 进程先死 | 边缘缓存直接顶住 |

静态站不是"更安全"，是**没有东西可以打**。扫描器来了只会拿到 404，没有登录框、没有接口、没有上传点。

## 迁移做了四件事

**一、把文章导出成 markdown。** WordPress 后台导出 XML，脚本转成一篇篇带 frontmatter 的 `.md`，放在 `content/posts/`。

**二、写个构建脚本。** 读 markdown 生成 HTML，顺手生成 sitemap、RSS 和列表页。这一步很值：以后发文章只写 md，其它全是产物。

**三、服务器只放产物。**

```
rsync -a --delete dist/blog/ /var/www/blog/
chown -R root:www-data /var/www/blog
find /var/www/blog -type d -exec chmod 755 {} +
find /var/www/blog -type f -exec chmod 644 {} +
```

属主 root、组 www-data、文件 644：web 进程只能读，改不了自己的页面。这一步比装任何防护插件都实在。

**四、nginx 断掉所有探针。**

```
location ~* ^/(?:\.|wp-|xmlrpc|phpmyadmin|\.env|\.git) { return 444; }
```

静态站上出现 `/wp-login.php` 只有一种解释：扫描器。直接断连，不回任何字节。

## 失去的东西

得说实话：没有评论、没有搜索、没有后台编辑器。

评论我直接砍了（一个人维护，评论区的价值不如它带来的垃圾和审核成本）。搜索用不上，文章不到三位数，分类加归档就够。编辑器更不需要，vim 比任何富文本框都顺手。

## 值得吗

对我值。日常改动只有一个动作：改 md → 跑 `build.py` → rsync。三分钟。而省下来的内存和运维时间，是每天都在付的。

如果你的站需要多作者、需要评论、需要插件生态，WordPress 仍然是对的。但如果你只是想写字，静态是更省事的那一边。

:::注意
静态站扛不住的是纯流量型 DDoS——那是带宽问题，靠 CDN 或高防解决，跟静态不静态无关。别把它当护身符。
:::

# 被扫描器盯上的那两周

> 2026-10-15 · 复盘

不是攻击，只是扫描。但每天几万次请求打到一个日活不到一百的小站上，日志和带宽都会难受。

这两周我把日志翻了一遍，得到的结论比想象中具体。

## 先看四条统计

```
LOG=/var/log/nginx/blog.access.log

# 谁在打
awk '{print $1}' $LOG | sort | uniq -c | sort -rn | head -20

# 在找什么
awk '{print $7}' $LOG | sort | uniq -c | sort -rn | head -20

# 状态码分布
awk '{print $9}' $LOG | sort | uniq -c | sort -rn | head

# UA
awk -F'"' '{print $6}' $LOG | sort | uniq -c | sort -rn | head -20
```

我的结果大概是这样：

- Top 路径里一半是 `/wp-login.php`、`/.env`、`/phpmyadmin`、`/config.json`
- 状态码 404 占七成，444 占两成（说明探针规则在干活）
- UA 里出现大量空 UA 和 `python-requests`

## 三个判断

**大量不同 IP、同一个 UA** → 僵尸网络。封 IP 没用，它们换得比你封得快。封 UA 或者上挑战。

**单一 IP、高频同一路径** → 脚本在跑。这种最省事，直接限速加封 IP。

**大量 404 但流量不高** → 只是有人在扫全网，不用管。回 444 断连就够了，省的是日志体积。

## 真正省带宽的两条规则

第一是探针路径直接 `return 444`：

```
location ~* ^/(?:\.|wp-|xmlrpc|phpmyadmin|\.env|\.git|\.svg/|cgi-bin) { return 444; }
location ~* \.(?:sql|bak|old|swp|ini|log|env|yml|zip|tar)$ { return 444; }
```

第二是空 UA 和扫描器 UA：

```
map $http_user_agent $bad_ua {
    default 0;
    ""      1;
    ~*(?:sqlmap|nikto|nmap|masscan|zgrab|nuclei|acunetix|dirbuster|gobuster|ffuf) 1;
}
```

命中就 `return 444`。这两条加完，日志体积掉了大概六成。

## 没用的做法

改端口、改路径、加 robots.txt 里的 Disallow——扫描器不看 robots，端口是全端口扫的。

还有"封 IP 段"：对家宽段动手会误伤真实用户，对机房段动手要封几百个 /24，维护成本比收益高。除非你有很明确的单点来源。

:::坑
别在办公网做压测或者规则验证。`limit_req` 是按 IP 的，同事会跟你一起被封十分钟，然后来找你。
:::

# nginx 限速：那几个参数到底在限制什么

> 2025-12-11 · 建站

`limit_req` 的参数看起来很简单，但 `rate` 和 `burst` 的关系大多数人第一次都理解反了。这篇用一个例子讲清楚。

## 先定义，再解释

```
limit_req_zone $binary_remote_addr zone=blog_req:10m rate=5r/s;

server {
    location / {
        limit_req zone=blog_req burst=30 nodelay;
        limit_req_status 429;
    }
}
```

- `rate=5r/s`：**补充速度**，不是上限。每 200ms 往桶里放一个令牌。
- `burst=30`：**桶的容量**，最多能攒 30 个令牌。
- `nodelay`：攒着的令牌可以一次性用掉，不排队。

所以真实上限是：短时间内最多 `burst` 个请求直接放行，之后按 `rate` 的速度慢慢恢复。

## 算一遍

`rate=5r/s` + `burst=30`：

- 第 1 秒来了 30 个请求 → 全部放行（30 个令牌瞬间用完）
- 接着来了 40 个 → 按每秒 5 个的速度恢复，能过几个是几个，其余 429
- 安静 6 秒 → 桶重新攒满 30 个

如果去掉 `nodelay`，那 30 个令牌会变成"排队等待"，请求会变慢但不报错。对网页来说排队伤害体验，对脚本来说排队等于没挡，所以静态站一律 `nodelay`。

## 参数怎么定

单个页面加载大概是 1 个 HTML + 3–6 个静态资源，浏览器并发 6。真人一分钟最多也就几十个请求。

我的经验值：

| 站类型 | rate | burst |
|---|---|---|
| 静态博客 | 5r/s | 30 |
| 带 API 的站 | 5r/s | 40–60 |
| 下载站 | 10r/s | 20（别太大） |

burst 给得太大等于没限速，给得太小正常用户会被误伤。测的时候用 20 并发打 60 次，看 429 的比例在 10%–40% 之间比较合理。

## 为什么你的限速不生效

三个高频原因，按出现频率排：

**一、zone 写在 server 里。** `limit_req_zone` 必须写在 http 级，放 server 或 location 里 `nginx -t` 会报错。

**二、挂了 CDN 没做 realip。** `$binary_remote_addr` 全是 CDN 节点地址，所有访客共用一个桶。表现是"一个人触发限速，全站跟着 429"，比不限速更糟。

**三、`limit_req` 和 `limit_conn` 搞混。** 前者限速率，后者限并发连接数。慢速攻击（一个连接慢慢发）要靠 `limit_conn`，快速 CC 要靠 `limit_req`，两个都要有。

:::坑
`limit_req_status` 默认是 503。很多人以为是限速没生效，其实是代码在测 429 而服务器返 503。显式写一行 `limit_req_status 429;` 省很多排查时间。
:::

## 验证

```
# 20 并发打 60 次，看状态码分布
seq 1 60 | xargs -P20 -I{} curl -s -o /dev/null -w '%{http_code}\n' \
  https://你的站/ | sort | uniq -c
```

正常应该看到一批 200 加一批 429。全是 200 说明阈值太宽，全是 429 说明第一层就被误伤——先查 realip。

# 一次 DNS 迁移的事故记录

> 2025-11-22 · 复盘

给域名换解析服务商，看起来是点几下鼠标的事。我那次掉了两个东西，一个是泛解析，一个是邮件验证。

记录一下，因为这类事故的共性是：**出错的时候你还不觉得出错。**

## 发生了什么

旧服务商（DNSPod）换到 Cloudflare，流程是：先在新家把记录建好，再改 NS。

我自认为建得很全：根域、www、blog、admin、api 都建了。改完 NS 大概半小时生效，主要站点都能开。

问题在第二天暴露：

- 一个客户说他的子域打不开——那是个临时测试域名 `demo-xxx.主域`，靠**泛解析 `*`** 生效的
- 另一个说收不到密码重置邮件——**TXT 的 SPF 记录**没迁

两条记录我都"以为"迁了。实际上泛解析在新服务商里是单独一条 `*` 记录，Cloudflare 不会自动推断；SPF 那条 TXT 因为长得长，我在复制的时候漏了后半个字符串。

## 后来改进的三件事

**一、迁之前先把旧记录导成文本，逐条对比。**

```
for n in @ www mail blog admin api '*' ; do
  echo "== $n"
  dig +short A     "${n}.example.com" 2>/dev/null
  dig +short AAAA  "${n}.example.com" 2>/dev/null
  dig +short CNAME "${n}.example.com" 2>/dev/null
  dig +short TXT   "${n}.example.com" 2>/dev/null
  dig +short MX    "${n}.example.com" 2>/dev/null
done > /root/dns-before.txt
```

**二、改完 NS 之后跑一遍同样的命令，用 diff 比。**

```
# 隔一天再跑一次，输出 dns-after.txt
diff <(sort dns-before.txt) <(sort dns-after.txt)
```

差异应该只有 TTL 和 NS 记录本身。出现别的行，就是漏的。

**三、TTL 提前降下来。** 迁移前 24 小时把所有记录的 TTL 从 3600 改成 300，出问题能快速回滚。这条我这次做了，帮了大忙——发现泛解析缺失后，改回去十分钟就恢复了。

## 三条经验

**泛解析要单独核对。** `*` 在新服务商里必须显式创建，而且它的优先级低于任何具体记录，很容易在测试时被具体记录掩盖。

**TXT 是最难搬的。** SPF、DKIM、各种域名验证都在 TXT 里，内容长、格式严。复制粘贴容易截断，一条 SPF 少个 `~all` 就会让邮件进垃圾箱而不是直接失败——更难发现。

**别在周五下午动 DNS。** 这条是老规矩，但每次都是它救命。

:::注意
如果域名同时用于邮箱，迁移后一定发一封测试邮件到 Gmail，看 SPF/DKIM 是否通过（Gmail 里"显示原始邮件"能看到）。收件箱正常不代表验证通过。
:::

# 用本地模型给旧文章补摘要和标签

> 2025-10-08 · 折腾

写了三年博客，早期那些文章没有摘要、没有标签，列表页上只有标题。手工补三十篇太烦，我用本地模型跑了一遍。

## 流水线

```
posts/*.md
   │  1. 读 frontmatter，跳过已有 summary 的
   ▼
[拼接提示词] ──► 本地模型（Ollama / llama.cpp）
   │  2. 要求输出严格 JSON
   ▼
校验 JSON ──► 写回 frontmatter
   │  3. 失败重试一次，两次失败就跳过并记日志
   ▼
生成报告：成功 N 篇 / 跳过 M 篇 / 失败 K 篇
```

关键是**要求模型输出 JSON**，而不是"给我一段摘要"。自然语言输出没法可靠地写回文件。

## 提示词

```
你是技术文档编辑。阅读下面的文章，输出严格的 JSON，不要任何解释文字：

{
  "summary": "60-90 字的中文摘要，说明这篇解决了什么问题，不要用'本文介绍了'开头",
  "tags": ["3-5 个标签，每个 2-6 个字，优先用文章里出现的技术名词"]
}

文章标题：{title}
正文：
{body}
```

三个细节决定成败：

- **字数写死**（60–90 字）。不写范围它会给你 20 字或者 300 字。
- **禁掉套话**。"本文介绍了"这类开头百分之百会出现，明确禁掉才有信息量。
- **标签要求"文章里出现过的"**。否则模型会编出文章里根本没提的技术。

## 校验

拿到 JSON 之后我做了四道检查，任何一道不过就跳过：

```
1. json.loads 能解析
2. summary 长度在 40–140 之间
3. tags 是 3–5 个字符串，长度 2–6
4. summary 里不包含原文前 20 个字（防它偷懒直接抄第一段）
```

第四条最有用。模型很容易把首段原样搬过来当摘要，那种摘要在列表页上毫无意义。

## 结果

30 篇里成功 26 篇，跳过 3 篇（原文本身没有 frontmatter，是更早期的手写 HTML），失败 1 篇（模型返回被截断）。

人工过一遍大概花了 20 分钟，改了 4 篇的摘要措辞。这个比例可以接受——**机器负责补齐结构，人负责把关措辞**。

## 值得自动化吗

如果只有 5 篇，不值得。写脚本 + 调提示词的时间比手工写完还长。

但这件事的真正价值不在这一次：脚本留在仓库里，以后每写一篇新文章，跑一下就能生成候选摘要和标签，你只判断"行不行"，不用"从零想"。这个边际成本才是划算的地方。

:::坑
本地小模型（7B 以下）在"严格输出 JSON"这件事上不稳，尤其输入长的时候会截断。要么换大一点的模型，要么把正文先截到 4000 字以内——技术文章的摘要用不到全文。
:::
