为了在国内也能看博客将网站移到了腾讯云上
前言
上一次在小兔的网站给挂马了,直接害怕,半年的心血直接没了,还上传豆包给的神秘脚本毛用没有,以后就只敢用静态页面,挂在vercel上,打开还要用魔法好麻烦,于是决定要搞一个国内也能访问的,所以选择了买域名加服务器备案,当然这次还是静态网站并且本地有备份,我绝不会重蹈覆辙。
为什么最后没用 EdgeOne Pages
原本用这个预览还挺好用的,但是备案要解析到主机上所以就直接不用这个了。
最终架构
- 域名:
lyh-blogs.top,根域名与www各一条 A 记录,直连服务器 - 服务器:腾讯云轻量应用服务器
- 系统:Ubuntu 22.04.5 LTS,内核 5.15.0-181-generic,2 核 1.9G 内存
- Web 服务器:Nginx 1.18.0
- 站点根目录:
/var/www/lyh-blogs,495 个文件约 72MB - HTTPS:Let’s Encrypt 签发,
lyh-blogs.top与www.lyh-blogs.top双域名,2026-12-24 到期,已装自动续期 - 发布方式:本地
pnpm build后用 tar 管道上传
目录结构上,仓库里多了一个 deploy/ 放服务器侧配置:
deploy/nginx-lyh-blogs.conf—— 站点 Nginx 配置deploy/server-setup.sh—— 服务器初始化脚本deploy/ssh-hardening.conf—— SSH 加固配置scripts/deploy-to-server.sh—— 本地构建 + 上传脚本
部署过程
服务器准备
买的时候没看清,买的是轻量应用服务器,原系统是 Windows Server,重装为 Ubuntu Server 22.04 LTS。
连通性排查
配好免密后第一次连接是 Operation timed out,而「超时」和「拒绝」的含义完全不同:
Connection refused—— 包到达了服务器的 TCP 栈,只是该端口没有服务在监听。防火墙是放行的Operation timed out—— 包被静默丢弃了。这是防火墙行为,不是服务问题
再用已知可达的主机做对照,排除本地出站限制:
nc -z -v -w 6 github.com 22github.com:22 能连通、目标机不能,说明问题在目标机那一侧。
最终从防火墙规则截图里找到了根因。
服务端初始化
sudo apt updatesudo apt install -y nginx rsync certbot python3-certbot-nginxsudo mkdir -p /var/www/lyh-blogssudo rm -f /etc/nginx/sites-enabled/defaultsudo nginx -t && sudo systemctl reload nginx站点配置里 try_files 的写法有讲究
上传构建产物
最终用的是 tar 管道。不要用 rsync
COPYFILE_DISABLE=1 tar czf - \ --exclude='._*' --exclude='.DS_Store' \ -C ./dist . \ | ssh $SSH_OPTS "$SERVER_USER@$SERVER_HOST" \ "tar xzf - -C $REMOTE_DIR --no-same-permissions \ && chmod 755 $REMOTE_DIR \ && find $REMOTE_DIR -type d -exec chmod 755 {} + \ && find $REMOTE_DIR -type f -exec chmod 644 {} +"解压后那三行 chmod 是必需的,不是冗余。
域名解析与 HTTPS
DNS 用 A 记录指向服务器 IP,根域名和 www 各一条。注意如果 www 上还留着指向旧平台的 CNAME,要先删掉 —— 同名记录不能共存。
等 dig +short lyh-blogs.top 返回 IP 之后再签证书:
sudo certbot --nginx -d lyh-blogs.top -d www.lyh-blogs.topcertbot 会自动改写站点配置加上 443 与证书路径,并创建 80 跳转,不需要手写 ssl_certificate。
备案号上页脚
两个文件配合:
src/config/footerConfig.ts——enable置为true,否则 HTML 不会被注入src/config/FooterConfig.html—— 写入备案号链接,指向https://beian.miit.gov.cn/
FooterConfig.html 末尾预留了注释块,公安联网备案通过后把平台下载的图标与 HTML 粘贴进去,与 ICP 备案号并列展示即可。
具体配置
第一步:把 Nginx 配置传到服务器
配置的源头在本地仓库 deploy/nginx-lyh-blogs.conf,服务器上还没有,得先送过去。
scp -i ~/.ssh/id_ed25519 \ deploy/nginx-lyh-blogs.conf \ ubuntu@你猜:/tmp/lyh-blogs.confscp是基于 SSH 的加密文件传输工具,能在本地和远程主机之间传文件,且全程加密。-iidentity file(身份文件)的缩写,用来指定用哪个私钥去做 SSH 认证。
为什么落在 /tmp 而不是直接传到 /etc/nginx/:/etc/nginx/sites-available/ 属于 root,普通用户写不进去。先落到 /tmp,再由初始化脚本用 sudo 搬过去 —— 这样上传这一步压根不需要提权,职责也更清楚:scp 只管搬运,脚本只管安装。
为什么改名成 /tmp/lyh-blogs.conf:脚本里硬编码读这个路径。用固定名字是为了让脚本可预测,不依赖原文件名。
确认传到了:
ssh -i ~/.ssh/id_ed25519 ubuntu@你猜 'ls -l /tmp/lyh-blogs.conf'-i 用来指定私钥。如果本地 ~/.ssh/config 里为该主机配了 IdentityFile,这一步可以省掉。
第二步:执行服务器初始化
scp -i ~/.ssh/id_ed25519 deploy/server-setup.sh ubuntu@你猜:/tmp/ssh -i ~/.ssh/id_ed25519 ubuntu@你猜 'bash /tmp/server-setup.sh'脚本按顺序做六件事:
- 装
nginx、rsync、certbot、python3-certbot-nginx - 创建站点目录
/var/www/lyh-blogs,并用SUDO_USER设置属主 - 删掉
/etc/nginx/sites-enabled/default - 把
/tmp/lyh-blogs.conf搬到sites-available/lyh-blogs,再软链到sites-enabled nginx -t校验语法,然后reload- SSH 加固(带前置检查,条件不满足会自动跳过)
脚本:server-setup.sh
首先是服务器上跑的脚本,在deploy目录下的
set -euo pipefail
SITE_NAME="lyh-blogs"SITE_ROOT="/var/www/lyh-blogs"set -euo pipefail 一个字母管一件事,三个都不能省:
-e—— 任何命令返回非零就立即退出。不加的话,脚本会在”装了一半”的中间状态里继续往下跑,把一个错误滚成一片错误-u—— 用了未定义的变量就报错退出。这是防手滑的保险丝:如果哪个变量名拼错了,不加-u时它会被当成空字符串展开,rm -rf "$DIR/"里的$DIR一空就变成rm -rf /-o pipefail—— 管道里任意一段失败,整条管道就算失败。默认只看最后一段的退出码,像apt install ... | grep ...这种写法,grep 成功就算成功,apt 失败会被悄悄吞掉
把站点名和路径抽成变量,是为了只有一个地方需要改。
第 1 段:装依赖
sudo apt updatesudo apt install -y nginx rsync certbot python3-certbot-nginxapt update必须单独先跑。它刷新软件包的索引清单,不跑就可能装到过期的版本、甚至找不到包报 404-y免交互自动确认,自动化脚本必需,否则会卡在Do you want to continue? [Y/n]python3-certbot-nginx是 certbot 的 Nginx 插件。不装它,后面certbot --nginx这个用法直接不可用rsync其实已经用不上了 —— 传输方式改成 tar 管道之后它就没参与过(原因见坑 3)。留着是为了以后想换回增量传输时不用重装
第 2 段:建目录并设属主
TARGET_USER="${SUDO_USER:-$USER}"sudo mkdir -p "$SITE_ROOT"sudo chown -R "$TARGET_USER":"$TARGET_USER" "$SITE_ROOT"这一段的核心是 SUDO_USER,见坑 2。简单说:用 sudo 跑脚本时 $USER 会变成 root,导致目录属主被设成 root,之后普通用户上传文件会失败。${SUDO_USER:-$USER} 取真实登录用户,:-$USER 是兜底 —— 万一没走 sudo,就退回用 $USER。
mkdir -p 的 -p 让”目录已存在”时不报错,这是为了脚本可以重复执行。
第 3 段:删掉默认站点
sudo rm -f /etc/nginx/sites-enabled/defaultUbuntu 装的 Nginx 会自带一个 default 站点,带 listen 80 default_server。不删的话,任何未匹配 server_name 的请求都会被它接走返回欢迎页,掩盖”我的配置到底生效了没有”这个判断。
-f 让文件不存在时也不报错 —— 同样是为了可重复执行(配合 set -e 尤其必要,否则第二次跑脚本会因为文件已被删而中断)。
第 4 段:前置检查并启用站点
if [[ ! -f /tmp/lyh-blogs.conf ]]; then echo "错误:找不到 /tmp/lyh-blogs.conf,请先把 Nginx 配置上传上来" exit 1fisudo mv /tmp/lyh-blogs.conf "/etc/nginx/sites-available/$SITE_NAME"sudo ln -sf "/etc/nginx/sites-available/$SITE_NAME" "/etc/nginx/sites-enabled/$SITE_NAME"为什么要先 [[ ! -f ]] 检查再 mv:文件不存在时 mv 本身就会报错退出,但报错信息是 No such file or directory,看不出缺的是哪个环节。先说清楚”缺配置,请先上传”,比让 mv 抛一个通用错误好懂得多。
ln -sf 的两个参数:
-s建软链接(symbolic link)-f目标已存在时直接覆盖,而不是报错
-f 同样是为了可重复执行 —— 幂等性是脚本设计里一个容易被忽略但很重要的目标。
第 5 段:校验并重载
sudo nginx -tsudo systemctl reload nginx为什么必须先 nginx -t:配置有语法错误时如果直接 reload,Nginx 会拒绝加载新配置,但老配置还在继续跑 —— 结果是”你以为更新了,其实没有”,而且没有任何明显报错。先校验能把问题立刻暴露出来。
为什么用 reload 而不是 restart:reload 是平滑重载,正在处理的请求不会被打断;restart 会断开所有连接。对这个场景差别不大,但在生产环境这是必须养成的习惯。
这里 set -e 起了保护作用:nginx -t 失败 → 脚本立即退出 → 不会执行到 reload。
第 6 段:SSH 加固
if [[ ! -s "$HOME/.ssh/authorized_keys" ]]; then echo " 跳过加固:..."elif [[ ! -f /tmp/ssh-hardening.conf ]]; then echo " 跳过加固:找不到 /tmp/ssh-hardening.conf。"else sudo cp -a /etc/ssh/sshd_config "/etc/ssh/sshd_config.bak.$(date +%Y%m%d%H%M%S)" sudo install -m 644 -o root -g root \ /tmp/ssh-hardening.conf /etc/ssh/sshd_config.d/00-hardening.conf if ! sudo sshd -t; then echo " 配置语法校验失败,已撤销改动" sudo rm -f /etc/ssh/sshd_config.d/00-hardening.conf exit 1 fi sudo systemctl reload ssh echo " 注意:先别关当前这个 SSH 会话,开一个新窗口验证能免密登录成功再关。"fi几个细节:
[[ -s ]]的-s是”存在且非空”,比-f严格。空的authorized_keys等于没有公钥,这种情况下关掉密码登录就会把自己永久锁在外面 —— 用-s才能挡住- 两道前置检查都通过才动手,否则宁可跳过。加固是好事,但锁死自己是灾难,这里选择保守
cp -a的-a保留权限、属主和时间戳,做备份用install -m 644 -o root -g root一条命令完成”复制 + 设权限 + 设属主”,比cp+chmod+chown三行更清晰- 失败回滚用的是
rm -f删掉自己刚放的文件,而不是拿备份覆盖回去。因为改动的全部内容就是”新增了一个 drop-in 文件”,删掉它等于完全回到原状,比覆盖更干净 - 最后那句”先别关当前会话”是操作纪律,不是客套话 —— 它保证你手里永远留着一条能用的连接
这个是本地的脚本,在scripts目录下的
配置区
SERVER_HOST="123.207.244.94" #懒得管了就这样吧SERVER_USER="ubuntu"SERVER_PORT="22"REMOTE_DIR="/var/www/lyh-blogs"SSH_KEY=""把机器相关的信息集中在一处,换服务器只改这一段。SSH_KEY 留空表示用默认密钥(~/.ssh/ 下的默认身份文件);要指定就填绝对路径。
算出仓库根目录
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"DIST_DIR="$ROOT_DIR/dist"拆开看:
${BASH_SOURCE[0]}是脚本自身路径(scripts/deploy-to-server.sh)dirname取出所在目录scripts/- 后面接
/..上跳一级,就是仓库根 cd ... && pwd把它变成绝对路径(cd进去再问”我在哪”)
为什么要这么绕:如果直接写相对路径 ./dist,那么从别的目录执行脚本就会找错地方。这样写无论从哪跑都指向同一个 dist/。
为什么用 ${BASH_SOURCE[0]} 而不是 $0:$0 在被 source 引入等场景下拿不到真实脚本路径,BASH_SOURCE 更可靠。
防呆检查
if [[ "$SERVER_HOST" == "CHANGE_ME" ]]; then echo "错误:请先编辑本脚本的配置区,把 SERVER_HOST 改成服务器公网 IP" exit 1fi
for cmd in pnpm tar ssh; do command -v "$cmd" >/dev/null 2>&1 || { echo "错误:本地找不到 $cmd" exit 1 }done- 第一段防止配置区没填就误跑(模板里默认值是
CHANGE_ME) - 第二段逐个检查依赖的命令是否存在。
command -v是标准做法,比which更可移植 - 核心思想是”早失败” —— 与其传到一半才发现本地没装 pnpm,不如开头就说清楚
构建并校验产物
cd "$ROOT_DIR"pnpm build
if [[ ! -f "$DIST_DIR/index.html" ]]; then echo "错误:构建产物缺少 index.html,构建可能已失败" exit 1fi这一条是整个脚本里最有价值的检查之一:构建命令返回 0 不等于产物正确。校验 index.html 存在,是”构建真的成功了”的底线。
没有这个检查会怎样:一个空的 dist/ 会被老老实实传上去,把线上正常的站点覆盖成空白 —— 而且是全量覆盖,连回滚的余地都没有。
(顺带一提:我在沙箱环境里遇到过”构建命令退出码非零、但构建其实成功了”的情况。所以判断构建成败要看产物,不能只看退出码,这个检查正好同时兜住了两个方向。)
SSH 选项
SSH_OPTS="-p $SERVER_PORT -o BatchMode=yes -o StrictHostKeyChecking=accept-new"if [[ -n "$SSH_KEY" ]]; then SSH_OPTS="$SSH_OPTS -i $SSH_KEY"fiBatchMode=yes—— 禁止一切交互式提问。没配免密时直接报错退出,而不是卡在密码提示上等着。自动化脚本最怕的就是静默卡住:看起来在跑,其实在等人输入StrictHostKeyChecking=accept-new—— 首次连接自动接受主机密钥,已存在但不匹配则拒绝。既不卡在Are you sure you want to continue connecting?,也不放弃对密钥变更的检查- 变量里只存选项、不含
ssh本身,调用处写成ssh $SSH_OPTS。见坑 6
权限预处理
chmod 755 "$DIST_DIR"打磨包前的本地目录权限。见坑 4 —— tar 会把 . 这个条目自身的权限写进包里,本地 700 就会让远端站点根目录也变成 700。
核心:tar 管道
COPYFILE_DISABLE=1 tar czf - \ --exclude='._*' \ --exclude='.DS_Store' \ -C "$DIST_DIR" . \ | ssh $SSH_OPTS "$SERVER_USER@$SERVER_HOST" \ "tar xzf - -C $REMOTE_DIR --no-same-permissions \ && chmod 755 $REMOTE_DIR \ && find $REMOTE_DIR -type d -exec chmod 755 {} + \ && find $REMOTE_DIR -type f -exec chmod 644 {} + \ && echo -n '服务器文件数: ' && find $REMOTE_DIR -type f | wc -l"逐项拆开:
本地这一侧(打包)
tar czf -——c创建归档,z用 gzip 压缩,f -表示输出到标准输出而不是文件。这就是整条管道成立的前提:不落盘,直接流给下一个命令-C "$DIST_DIR" .—— 先切换到dist/目录,再把.(当前目录的全部内容)打包。注意打的是内容不是目录本身,所以解压后不会多套一层dist/目录--exclude='._*'—— 过滤 macOS 的 AppleDouble 文件(形如._xxx)。它们是资源分支元数据,传上去纯属垃圾--exclude='.DS_Store'—— 过滤 Finder 生成的目录缓存文件COPYFILE_DISABLE=1—— 环境变量,让 macOS 的 tar 不打包扩展属性(xattr)。不加的话远端会刷一屏Ignoring unknown extended header keyword 'LIBARCHIVE.xattr...'警告
远端那一侧(解包 + 收敛权限)
tar xzf -——x解包,从标准输入读(配合前面的|)--no-same-permissions—— 解压时不照搬包里的权限,改用 umask 收敛。但它挡不住 700,原因见坑 4chmod 755 $REMOTE_DIR+ 两条find ... -exec chmod—— 这才是真正的保险。解压完统一把目录设成 755、文件设成 644,覆盖掉任何漏过来的异常权限-exec ... {} +比-exec ... \;快得多 —— 前者一次把多个文件名传给一条 chmod 命令,后者每个文件都启动一个新进程find ... | wc -l—— 数文件数。关键就在这个命令是在远端跑的,所以它输出的是文件真实落地的数量,而不是本地的统计
set -euo pipefail 在这里的作用
管道里任何一段失败(本地 tar 出错、SSH 连不上、远端解压失败),整条命令就算失败,脚本立刻停止。不会出现”传了一半却报成功”的情况。
不用脚本也可以搞定
步骤 1:本地确认配置在
cd /Users/liyuhang/代码库/LYH-Blogsls deploy/nginx-lyh-blogs.conf步骤 2:把配置传到服务器
scp -i ~/.ssh/id_ed25519 \ deploy/nginx-lyh-blogs.conf \ ubuntu@123.207.244.94:/tmp/lyh-blogs.conf冒号前面是本地路径,后面是服务器路径。
步骤 3:登录服务器
ssh -i ~/.ssh/id_ed25519 ubuntu@123.207.244.94以下步骤 4 到 6 都在服务器上执行。
步骤 4:装环境
sudo apt updatesudo apt install -y nginx certbot python3-certbot-nginx步骤 5:建站点目录
sudo mkdir -p /var/www/lyh-blogssudo chown ubuntu:ubuntu /var/www/lyh-blogs注意这里是手写 ubuntu:ubuntu,不是脚本里的 ${SUDO_USER:-$USER}。那个变量存在的意义是让脚本在任意用户名下都能跑;手工敲的时候你自己知道是谁,直接写清楚更直观。
步骤 6:启用站点
sudo rm -f /etc/nginx/sites-enabled/defaultsudo mv /tmp/lyh-blogs.conf /etc/nginx/sites-available/lyh-blogssudo ln -sf /etc/nginx/sites-available/lyh-blogs /etc/nginx/sites-enabled/lyh-blogssudo nginx -tsudo systemctl reload nginx步骤 7:回到本地构建
exit # 退出 SSH 会话cd /Users/liyuhang/代码库/LYH-Blogspnpm buildls dist/index.html # 确认产物存在步骤 8:手工上传
chmod 755 distCOPYFILE_DISABLE=1 tar czf - --exclude='._*' --exclude='.DS_Store' -C dist . \ | ssh -i ~/.ssh/id_ed25519 ubuntu@123.207.244.94 \ "tar xzf - -C /var/www/lyh-blogs --no-same-permissions"然后单独收敛权限:
ssh -i ~/.ssh/id_ed25519 ubuntu@123.207.244.94 ' chmod 755 /var/www/lyh-blogs find /var/www/lyh-blogs -type d -exec chmod 755 {} + find /var/www/lyh-blogs -type f -exec chmod 644 {} + find /var/www/lyh-blogs -type f | wc -l'脚本里这几步是用 && 串在一条命令里的。手工敲建议拆开 —— 合并执行快,但一旦出错,你不知道是哪一步的问题。分开跑,报错就定位。
最后一行打印的文件数用来确认传成功了。
步骤 9:验证服务端就绪
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: lyh-blogs.top" http://123.207.244.94/curl -s -o /dev/null -w "%{http_code}\n" --resolve lyh-blogs.top:443:123.207.244.94 https://lyh-blogs.top/- 步骤 4 到 6 是在服务器上敲的,别在本地敲。本地的
sudo rm -f /etc/nginx/sites-enabled/default删的是自己电脑上的东西 scp那条命令里两个路径别搞混:deploy/nginx-lyh-blogs.conf是本地文件,/tmp/lyh-blogs.conf是服务器上的目标,中间那个冒号是分界- 权限收敛那三行不能省。省掉就可能整站 404,而且症状很有迷惑性:文件数正常,但页面打不开(见坑 4)
- 出问题看两处:
sudo nginx -t看配置语法,sudo tail -30 /var/log/nginx/error.log看运行时错误
第三步:本地构建并上传站点
在本地执行,不是在服务器上:
bash scripts/deploy-to-server.sh脚本做三件事:
- 本地
pnpm build(含 pagefind 搜索索引生成) - 校验
dist/index.html是否存在 —— 防止构建静默失败还把空目录传上去 - 用 tar 管道把
dist/全量上传,并在远端做权限收敛
输出解读:
==> 本地构建...==> 上传到 ubuntu@你猜:/var/www/lyh-blogs服务器文件数: 495==> 完成,访问 https://lyh-blogs.top 查看关键是 服务器文件数: 495 这一行 —— 它是在远端解压完成后现数出来的,不是本地的统计。这是”文件真的落地了”的直接证据,比本地的 sent N bytes 可靠得多。
构建输出里的两类”看起来像错误”的正常信息:
tar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'—— macOS 扩展属性的提示,无害。COPYFILE_DISABLE=1和环境变量能减少一部分,但不能完全消除Note: Pagefind doesn't support stemming for the language zh-cn—— pagefind 对中文不支持词干提取,只是提示搜索不会做词形还原,功能正常
第四步:用 Host 头验证服务端就绪
这一步的目的:在改 DNS 之前,确认这台服务器已经能正确响应请求。避开”DNS 已经切过来但站点还没好”的空窗期。
为什么需要特殊手法:域名还没解析到这台机器,直接 curl https://lyh-blogs.top/ 走的是旧的(或空的)解析结果,打不到我们想看的目标。所以要绕过 DNS,直接和这台服务器对话。
有三种手法,由浅到深。
手法 A:Host 头(走 HTTP)
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: lyh-blogs.top" http://你猜/原理:HTTP/1.1 的 Host 头正是 Nginx 用来匹配 server_name 的依据。手动指定它,等于告诉 Nginx「假装我在访问这个域名」,于是它就会按我们那份配置来处理请求。
注意返回码会随阶段变化,这一点容易让人困惑:
- 签证书之前 —— 应当返回
200,这就是服务端就绪的信号 - 签证书之后 —— 返回
301。因为 certbot 把 80 端口改成了跳转 HTTPS。此时301同样说明配置生效了,只是含义从”能服务”变成了”会跳转”
实测当前状态:
Host=lyh-blogs.top → 301Host=www.lyh-blogs.top → 301附带一个诊断价值:填个乱值试探:
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: nothing.example" http://你猜/返回 404。这个结果说明确实只启用了我们自己的站点配置 —— 如果 5.2 节里没删掉 sites-enabled/default,这里会返回 Nginx 欢迎页的 200。所以这条命令其实是在反向验证第 2 步做对了。
手法 B:--resolve(走 HTTPS,验证完整链路)
curl --resolve lyh-blogs.top:443:你猜 https://lyh-blogs.top/原理:告诉 curl「解析 lyh-blogs.top 时直接用 你猜」,但 URL 里仍然写域名。这样 SNI 和证书校验用的都是真实域名,能完整走通 HTTPS 链路,而完全不碰 DNS。
比手法 A 更强的地方在于:它验证的是最终用户实际会走的协议。手法 A 只测了 Nginx 的 server_name 匹配逻辑,手法 B 连 TLS 握手、证书匹配、443 监听一起测了。
实测:
https 首页 → 200 0.37shttps 文章页 → 200手法 C:看响应头(验证缓存与安全策略)
curl -s -I --resolve lyh-blogs.top:443:你猜 https://lyh-blogs.top/实测输出(节选):
HTTP/1.1 200 OKServer: nginx/1.18.0 (Ubuntu)X-Content-Type-Options: nosniffServer 头确认了前面挡着的是 Nginx 1.18.0,X-Content-Type-Options 确认配置里那几行安全头真的生效了 —— 配置文件写了不等于生效,这是”写了”到”生效”的验证。
踩坑记录
网络层:云防火墙 IPv4/IPv6 来源不匹配
现象:SSH 端口 22 和 443 全部超时,80 端口是 Connection refused。
排查过程:先确认主机在线(ping 通),再确认本地出站正常(github.com:22 能连),剩下的只能是目标机侧。看防火墙规则截图时发现关键点 —— 腾讯云轻量防火墙的规则有「来源」字段,而 22 和 443 规则的来源被设成了**「全部IPv6地址」**,服务器公网 IP 是 IPv4,所以这两条规则对实际流量完全不生效。
修复:两条规则的来源改为「全部IPv4地址」。同页面上还残留着 3389(Windows 远程桌面的默认端口)规则,顺手删掉减少暴露面。
教训:防火墙规则”看起来开了”和”真的放行了”是两件事,来源 这类限定字段容易被忽略。
权限层:sudo 下 $USER 变成 root
现象:初始化脚本里用 chown -R "$USER" 设置站点目录属主,结果目录属主变成了 root:root,后续上传文件时报大量 Permission denied。
原因:sudo 会重置环境变量,USER 变成 root,拿不到真实登录用户。
修复:
TARGET_USER="${SUDO_USER:-$USER}"SUDO_USER 是 sudo 保留的原始登录用户名,:-$USER 是兜底 —— 没用 sudo 直接执行时 SUDO_USER 为空,退回 USER。
工具兼容层:macOS 自带的 rsync 是 openrsync
现象:rsync 传输时文件列表正常列出,显示 sent 1023838 bytes,看起来传完了,最后却报:
rsync(96700): warning: child 96702 exited with status 23而服务器上一个文件都没有收到。
排查过程:用 rsync --version 查版本,输出是:
openrsync: protocol version 29rsync version 2.6.9 compatible也就是说 macOS 自带的是 OpenBSD 实现的 openrsync,协议版本 29,与服务器上的 GNU rsync 不兼容。“sent 了字节数”是误导性输出 —— 那只是本地统计,不代表对端接收成功。
修复:改用 tar 管道。只依赖 tar 和 ssh,稳定可靠。代价是每次全量传输(本站 72MB,实际耗时可接受)。如果以后想要增量传输,可以 brew install rsync 装 GNU 版再换回去。
另一个容易误判的点:macOS 上执行 tar 会打印一堆
tar: Ignoring unknown extended header keyword 'LIBARCHIVE.xattr.com.apple.provenance'这是 macOS 扩展属性(xattr)的提示,无害,不是错误。
权限层:tar 会把 dist/ 的 700 权限带到站点根目录
这个坑值得单独讲,因为它复发过一次,而且第一次的结论是错的(对照复盘见第六节)。
现象:部署完成后整站返回 404,但服务器上文件数完全正常。
排查过程:第一反应是”文件没传上去”,但 find -type f | wc -l 显示 434 个文件都在。查 Nginx 错误日志看到真正的证据:
[crit] stat() "/var/www/lyh-blogs//index.html" failed (13: Permission denied)再看目录权限:
ls -ld /var/www/lyh-blogsdrwx------ 20 ubuntu ubuntu 4096 Sep 25 13:55 /var/www/lyh-blogs700。Nginx 以 www-data 身份运行,连目录都进不去,所以任何请求都读不到文件。
根因:打包命令是 -C ./dist .,「.」自身也是一个 tar 条目,它的权限会被原样写进包里。本地 dist/ 恰好是 700(Astro 构建偶尔会产出这种权限),于是解压后站点根目录也是 700。
关键认知修正:--no-same-permissions 挡不住这个情况。它的作用是按 umask 收敛权限,而:
700 & ~022 = 700按位与之后仍然是 700,等于完全没起作用。这个参数只能防住”文件权限比 umask 更宽”的情况,防不住”目录本身就不可读”。
症状会随 try_files 写法变化:
- 写
try_files $uri $uri/ =404;→ 返回 403,日志关键字是directory index of ... is forbidden - 写
try_files $uri $uri/index.html =404;→ 返回 404,日志关键字是stat() ... failed (13: Permission denied)
修复(应急):
sudo chmod 755 /var/www/lyh-blogssudo find /var/www/lyh-blogs -type d -exec chmod 755 {} +sudo find /var/www/lyh-blogs -type f -exec chmod 644 {} +修复(根治):上传脚本里做两件事 —— 打包前 chmod 755 "$DIST_DIR",解压后远端再统一 chmod 一遍兜底。
排查习惯:看到 404 时,先 ls -ld <站点根目录> 比 curl 管用。文件数正常却 404,几乎一定是权限问题。
服务语义层:/posts/ 返回 403 而不是 404
现象:访问 /posts/ 返回 403 而不是 404。
原因:主题没有独立的 /posts/ 列表页,该目录下只有各篇文章的子目录。Nginx 配置写成 try_files $uri $uri/ =404; 时,$uri/ 会让它尝试对目录做索引,列目录被禁止就返回 403,顺带暴露了目录结构的存在。
修复:改为显式指向索引文件:
try_files $uri $uri/index.html =404;这样”目录存在但没有索引文件”的情况返回 404,语义正确,也不泄露目录结构。
Shell 脚本层:变量里误含 ssh 前缀
现象:
ssh: Could not resolve hostname ssh: nodename nor servname provided, or not knowntar: Write error原因:把整条命令拼进了变量再调用,拼出了 ssh ssh:
SSH_CMD="ssh -p $PORT -o BatchMode=yes"ssh $SSH_CMD "$USER@$HOST" # 展开后是 ssh ssh -p ...一个增加排查难度的连带现象:tar: Write error 会同时出现,因为管道下游的 ssh 秒退,tar 写不进去。这个报错很容易让人误以为 tar 有问题,实际根因在 SSH 那一段。
修复:变量只存选项,不含命令本身:
SSH_OPTS="-p $PORT -o BatchMode=yes"ssh $SSH_OPTS "$USER@$HOST"内容层:绝对路径的图片引用会 404
现象:某篇文章里的图片在线上全部不显示。
排查过程:批量扫描所有文章的图片引用:
grep -rnE '!\[[^]]*\]\(/[^)]*\.(png|jpg|jpeg|webp|avif|gif)\)' src/content/posts/找到 5 处错误引用。实测对照两种写法:
./images/moectf-2026/level1.jpg→ 构建成/_astro/level1.Deo-b12t.jpg→ 访问 200/posts/images/moectf-2026/level1.jpg→ 原样输出 → 访问 404
原因:Astro 的内容集合(content collection)只会处理相对 .md 文件的图片路径,交给构建管线做压缩和格式转换(输出成 /_astro/<名字>.<哈希>.webp)。而以 / 开头的路径会被当成「放在 public/ 目录下的静态文件」原样输出,但这些图片并不在 public/ 下,所以线上必然 404。
另一个更隐蔽的错法:括号被误写成花括号。
![题目图片]{/posts/images/moectf-2026/cat.png}这不会报任何错,日志里干干净净,只是整段渲染成纯文本。这种错误只能靠肉眼或扫描构建产物发现。
修复:5 处引用全部改为相对路径。修复后裸路径计数归零,5 张图线上全部 200。
预防:部署后用构建产物做终检,比逐页点快得多:
grep -rc '/posts/images/' dist/posts/*/index.html # 应全部为 0安全层:云镜像的 SSH 出厂配置过宽
背景:在讨论”服务器 IP 泄露是否有风险”时顺带排查了 SSH 配置。
实测现状:
PermitRootLogin yes # 允许 root 直接登录PasswordAuthentication yes # 允许密码登录passwd -S ubuntu → P # ubuntu 账户有可用密码passwd -S root → P # root 账户也有可用密码四条叠起来 = 22 端口对全网开放的同时,攻击者不需要密钥,猜中密码就能以 root 身份登入。
当时 auth.log 里只有用户自己的连接记录,还没有真实爆破 —— 但服务器当时才上线 50 分钟,扫描是必然会来的。
加固方式:写 drop-in 配置而不是改主文件:
sudo tee /etc/ssh/sshd_config.d/00-hardening.conf >/dev/null <<'EOF'PasswordAuthentication noKbdInteractiveAuthentication noPermitEmptyPasswords noPermitRootLogin noX11Forwarding noEOF为什么 drop-in 能生效:/etc/ssh/sshd_config 第 12 行是 Include /etc/ssh/sshd_config.d/*.conf,位置在主文件靠前;而 sshd 对同一指令取第一个读到的值。所以 drop-in 里的设置会覆盖主文件后面的同名项(本例中是主文件第 33 行的 PermitRootLogin yes 和第 123 行的 PasswordAuthentication yes)。用 00- 前缀是为了排在未来的 10-、50-cloud-init.conf 之类前面,保持最高优先级。
改 SSH 配置最大的风险是把自己锁死,所以用了「延迟自动回滚」:
sudo rm -f /tmp/ssh_hardening_oksudo setsid nohup bash -c ' sleep 120 if [ ! -f /tmp/ssh_hardening_ok ]; then rm -f /etc/ssh/sshd_config.d/00-hardening.conf systemctl reload ssh fi' >/dev/null 2>&1 &
sudo sshd -t && sudo systemctl reload ssh逻辑是:先挂一个 120 秒的定时任务,到点如果没收到确认标记就自动还原配置并 reload。然后立刻用新连接验证,通过就 touch /tmp/ssh_hardening_ok 解除保险。即使新配置有问题,两分钟后也会自动恢复成宽松配置,还能用密码或控制台进去重来。
两个细节:
setsid是必需的 —— 不然断开 SSH 时后台任务会被 SIGHUP 带走,保险就失效了- 用
reload而不是restart—— 现有连接不会被断开,出问题还有一条活路
验证三条:
# 1. 密钥仍可用(最重要)ssh -i ~/.ssh/id_ed25519 ubuntu@123.207.244.94 'whoami'
# 2. 密码登录被拒 → Permission denied (publickey)ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no ubuntu@123.207.244.94 true
# 3. root 被拒 → Permission denied (publickey)ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no root@123.207.244.94 true三条全部符合预期。
fail2ban:装了 v0.11.2,/etc/fail2ban/jail.local 设为 5 分钟内失败 5 次封 1 小时,ignoreip 里写入了本机 IP 防止误封自己(fail2ban-client status sshd 确认 jail 已挂载并监控 /var/log/auth.log)。
环境层:沙箱与后台任务的冲突
这部分是工具使用上的坑,不是服务器问题,但同样会卡住流程。
run_in_background与提权不能同时生效:后台任务仍受沙箱约束,读取~/.ssh私钥会被拒(Load key "/Users/.../id_ed25519": Operation not permitted)。需要读私钥的命令必须前台执行- SSH 首次连接的主机密钥提示会挂住后台任务:必须加
-o BatchMode=yes -o StrictHostKeyChecking=accept-new,否则任务会卡在交互提示上而不是报错退出 - 构建脚本非零退出码的误判:沙箱阻止 pnpm 清理缓存导致构建命令返回非零码,但构建本身实际成功了(输出里有
[build] Complete!)。判断构建是否成功要看产物,不要只看退出码 - GitHub Actions 监听分支错误:
.github/workflows/deploy.yml监听master,而仓库主分支是main,导致这个 workflow 从未真正触发过。站点迁移到自有服务器后已改为仅手动触发
关于 --no-same-permissions
当时的错误结论(原文):
tar 解压会恢复源目录权限。本地
dist/若是 700,解压后站点根目录也变 700……远端解压加--no-same-permissions,或事后sudo chmod 755 <站点目录>。
为什么当时看起来成立:加上这个参数、顺手 chmod 之后,站点确实恢复了 200。看起来因果关系明确。
为什么实际是错的:站点恢复是因为顺手做了 chmod,不是因为 --no-same-permissions 起了作用。当时把两个动作一起做了,没法区分是谁生效的 —— 这是排查中的一个典型错误:同时改动多个变量,导致无法归因。
新证据:后来重新部署时站点又 404 了。这次只加参数没做 chmod,问题原样复现。推导一下就能明白:
700 & ~022 = 700umask 只能去掉权限位,不能增加。700 已经没有任何组/其他用户的权限位,与运算之后还是 700。
修正后的结论:--no-same-permissions 只能防住”文件权限比 umask 更宽”的情况,防不住”目录本身不可读”。根治办法是打包前 chmod 755 源目录,配合解压后统一 chmod 兜底。
教训:验证修复时,一次只改一个变量。同时改多个,成功了也不知道是哪个对,失败了也不知道是哪个错。
关于 404 的第一反应
当时的错误推理:首页 404 → 部署失败了 → 文件没传上去。
推翻它的证据:find /var/www/lyh-blogs -type f | wc -l 返回 434,文件一个不少。
修正后的排查顺序:文件数正常却整站 404,直接跳到权限检查(ls -ld),不要浪费时间在传输环节。
教训:404 是 Nginx 的通用兜底状态码,它同时表示”文件不存在”和”文件读不到”。同一个状态码背后可能是完全不同的层,得看错误日志区分。
关于 fail2ban 的测试样本
当时的错误推理:用一批伪造日志行测 fail2ban-regex,结果显示 3 行里只有 2 行命中,于是判断”过滤器漏掉了 Invalid user 这类行”。
为什么实际是错的:我造的样本不真实。真实 sshd 日志里 Invalid user 那一行不带 ssh2 后缀:
真实格式:Invalid user admin from 203.0.113.6 port 55555我造的: Invalid user admin from 203.0.113.6 port 5555 ssh2ssh2 后缀只出现在 Failed password / Failed publickey 这类行上。用真实格式重测,结果是 6 行 0 missed,没有问题。
教训:用伪造样本测试时,样本必须先对齐真实格式,否则测出来的是自己的臆想。这类测试的第一步应该是从真实日志里复制一行做模板。
一个意外的真实发现
在修正测试样本的过程中,反而发现了一个真有价值的事实:默认的 sshd 过滤器把 Failed publickey 行放进 ignoreregex 有意忽略了。
这是设计如此 —— 公钥不可爆破,密钥试探失败属于正常噪音,全部记录会污染统计。实际会计入封禁的是:
Failed password for ...—— 密码爆破Invalid user ...—— 账户枚举error: maximum authentication attempts exceeded ...—— 认证次数超限
实际含义:关掉密码登录之后,fail2ban 在 SSH 上的价值就只剩降噪了。它真正的意义在于后面接入的带登录功能的服务(评论系统后台等)。
关于「服务器 IP 是否为敏感信息」
最初的担心:IP 会不会泄露,需不需要藏起来。
查证后的事实:
dig lyh-blogs.top @8.8.8.8直接返回123.207.244.94,任何人都能查到- 浏览器访问网站时本来就需要知道这个 IP
- 公安联网备案表单要求主动填写
- 公开的 GitHub 仓库里有 4 个文件写着它
修正后的结论:IP 藏不住,也不值得藏。 它本来就是为了被解析才存在的。
真正决定安全的不是 IP 有没有被知道,而是两件事:
- 谁能登录 —— 现在是只认密钥,爆破没有着力点
- 对外开了哪些端口 —— 只有 22、80、443
顺着这个思路排查时,才发现了 5.8 节那个真正严重的问题(root + 密码登录全开)。如果停留在”IP 要不要藏”这个层面上,这个漏洞就会被忽略。
运维速查
日常更新:
pnpm new-post 文章名 # 新建文章pnpm dev # 本地预览 localhost:4321bash scripts/deploy-to-server.sh # 构建 + 上传 + 上线发布后验证:
curl -s -o /dev/null -w "%{http_code}\n" https://lyh-blogs.top/curl -s -o /dev/null -w "%{http_code}\n" https://lyh-blogs.top/posts/文章名/服务器排障:
ssh -i ~/.ssh/id_ed25519 ubuntu@123.207.244.94
ls -ld /var/www/lyh-blogs # 权限必须是 drwxr-xr-xsudo tail -30 /var/log/nginx/error.log # Nginx 报错sudo ss -tlnp # 开了哪些端口sudo fail2ban-client status sshd # 有没有人在爆破sudo certbot certificates # 证书还剩多久sudo nginx -t && sudo systemctl reload nginx # 改完 Nginx 配置文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!