拿到一台全新的 Ubuntu 或 Debian VPS 后,第一个小时要先关掉自动化扫描器在 IP 上线几分钟内就会尝试的那些入口。更新系统,用带 sudo 权限和 Ed25519 密钥的普通用户登录,通过 SSH drop-in 文件禁用密码登录和 root 登录,再启用默认拒绝的防火墙,只开放 SSH 和 Web 端口,最好只对你的 CDN 开放。接着开启带计划重启的无人值守安全更新,加上 fail2ban 或 CrowdSec,用 systemd 给应用套上沙箱,并在 DNS 指向服务器之前配好备份和告警。
下面的命令以 Ubuntu 24.04 LTS 为基准,并标注 Debian 12 和 13 的差异。操作期间请保持服务商的网页控制台处于打开状态。一旦 SSH 或防火墙的改动把你锁在门外,它就是回到服务器的唯一通道。
更新基础系统并创建 sudo 用户
很多服务商交付的是带密码的 root 登录,所以第一步就是把这两样都换掉。先安装待处理的更新,再创建一个拥有 sudo 权限的个人账户:
apt update && apt full-upgrade -y
adduser deploy
usermod -aG sudo deploy
Ubuntu 云镜像已经自带拥有 sudo 权限的 ubuntu 账户。在设置了 root 密码的 Debian 最小化安装中,可能没有 sudo,需要先执行 apt install sudo。给每个人分配独立的具名账户,这样审计记录才能对应到具体的人。统一使用 UTC 能简化跨服务器和 CDN 控制台的日志关联:timedatectl set-timezone Etc/UTC。
Debian 与 Ubuntu 24.04 的差异
| 项目 | Ubuntu 24.04 LTS | Debian 12 和 13 |
|---|---|---|
| SSH 监听 | 由 ssh.socket 按需启动守护进程 |
通常是 ssh.service,用 systemctl status 确认 |
| 防火墙 | 已安装 ufw,但未启用 |
默认未启用任何规则,默认框架是 nftables |
| 自动更新 | 已安装并启用 unattended-upgrades |
可能未安装,需要安装并启用 |
| 认证日志 | /var/log/auth.log 和 systemd 日志 |
只有 systemd 日志,自 Debian 12 起默认不再安装 rsyslog |
sudo |
已安装 | 最小化安装中可能缺失 |
| OpenSSH | 9.6 | Debian 12 为 9.2,Debian 13 为 10.0 |
使用 Ed25519 密钥和加固 drop-in 的 SSH
创建并安装密钥
在自己的工作电脑上生成密钥,绝不要在服务器上生成。Ed25519 密钥短小、速度快,所有当前版本的 OpenSSH 都支持。用口令保护私钥,并交给 ssh-agent 保管。如果你有 FIDO2 安全密钥,ssh-keygen -t ed25519-sk 可以把密钥绑定到硬件上。
ssh-keygen -t ed25519 -C "deploy@laptop-2026"
ssh-copy-id -i ~/.ssh/id_ed25519.pub [email protected]
ssh-copy-id 需要一次密码登录。如果 root 已经接受你的密钥且密码登录已关闭,就直接在服务器上复制密钥文件:
sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
sudo install -m 600 -o deploy -g deploy /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
权限很重要:在默认开启 StrictModes 的情况下,sshd 会忽略其他用户可写的密钥文件。
编写 drop-in 文件
Ubuntu 的 /etc/ssh/sshd_config 第一行就是 Include /etc/ssh/sshd_config.d/*.conf,而 sshd_config 手册规定,每个关键字以第一次读到的值为准。drop-in 文件按字典序读取,并优先于主文件的其余部分。云镜像往往自带 50-cloud-init.conf 或 60-cloudimg-settings.conf 之类的文件来设置 PasswordAuthentication,所以给你自己的文件加一个较小的前缀:
# /etc/ssh/sshd_config.d/00-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowUsers deploy
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
KbdInteractiveAuthentication no 会关闭 PAM 的 keyboard-interactive 通道,否则即使关闭了 PasswordAuthentication,它仍可能提示输入密码。AllowUsers 把登录限制在指定账户上。Ubuntu 主配置文件里写的是 X11Forwarding yes,由于 drop-in 先被读取,最终以它为准。如果没人需要转发,再加上 AllowAgentForwarding no 和 AllowTcpForwarding no。
检查语法、打印实际生效的值,然后应用:
sudo sshd -t
sudo sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|kbdinteractive|authenticationmethods|allowusers'
sudo systemctl restart ssh
sshd -t 能发现语法错误,sshd -T 则显示真正生效的配置,包括 cloud-init 的文件是否抢先生效。重启 ssh 不会断开已建立的会话。在 Ubuntu 24.04 上,监听套接字归 ssh.socket 管理,因此修改 Port 或 ListenAddress 时,还需要在防火墙放行新端口之后执行 sudo systemctl daemon-reload 和 sudo systemctl restart ssh.socket。其余选项见 Ubuntu 的 OpenSSH server 指南。
关闭第一个会话前先用第二个会话测试
保持当前会话不要关闭。在新终端里用密钥以 deploy 登录,执行 sudo -v。然后确认已关闭的途径确实无法登录:
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password [email protected]
ssh [email protected]
两次尝试都应以 Permission denied (publickey) 结束。确认之后再关闭原来的会话。把 SSH 改到其他端口可以减少日志噪音,但它不是安全控制措施。
默认拒绝的防火墙:ufw 或 nftables
主机防火墙是服务商网络防火墙之后的第二层防护(如果服务商提供的话)。除 SSH 和 Web 端口外,丢弃所有入站流量,允许出站流量,并且像对待 IPv4 一样覆盖 IPv6。
Ubuntu:ufw
先添加 SSH 规则再启用防火墙,否则 ufw enable 会切断你当前的会话:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw limit OpenSSH
sudo ufw allow proto tcp from any to any port 80,443 comment 'web'
sudo ufw enable
sudo ufw status verbose
根据 ufw 手册,limit 规则会拒绝在 30 秒内发起 6 次及以上连接的地址。这能拖慢扫描器,但也可能误伤会并行打开大量 SSH 连接的自动化工具,这种情况下用普通的 allow 更合适。如果你有固定的管理地址或 VPN,就只允许从那里访问 SSH:sudo ufw allow from 198.51.100.7 to any port 22 proto tcp。只要 /etc/default/ufw 中保持默认的 IPV6=yes,规则同样适用于 IPv6。
Docker 文档提醒,Docker 发布的端口会绕过 ufw 和 firewalld 的规则。请把容器发布在回环地址上,例如 -p 127.0.0.1:3000:3000,由反向代理面对公网。
Debian:nftables
Debian 使用 nftables 作为防火墙框架。下面的 /etc/nftables.conf 实现同样的策略,并为下一节的 CDN 地址段预先创建具名集合:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
set cloudflare_v4 {
type ipv4_addr
flags interval
}
set cloudflare_v6 {
type ipv6_addr
flags interval
}
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iif "lo" accept
meta l4proto { icmp, ipv6-icmp } accept
tcp dport 22 accept
ip saddr @cloudflare_v4 tcp dport { 80, 443 } accept
ip6 saddr @cloudflare_v6 tcp dport { 80, 443 } accept
}
chain forward {
type filter hook forward priority filter; policy drop;
}
}
用 sudo nft -c -f /etc/nftables.conf 检查文件,然后执行 sudo systemctl enable --now nftables。ICMPv6 必须保持开放,因为 IPv6 的邻居发现和路径 MTU 发现都依赖它。flush ruleset 也会删除 fail2ban 或 Docker 创建的表,所以重新加载规则后要重启这些服务。每台主机只用一种防火墙管理工具。
只让 CDN 访问 Web 端口
如果站点位于 Cloudflare 或其他 CDN 之后,源站就不应在 80 和 443 端口上响应其他任何人。否则,任何人只要通过旧的 DNS 记录、同一 IP 上的邮件服务器或全网扫描找到源站地址,就能绕过 CDN 的 WAF、限流和缓存。Cloudflare 在 https://www.cloudflare.com/ips-v4 和 https://www.cloudflare.com/ips-v6 公布其地址段,其 IP 地址文档说明,新地址段会在投入生产之前先加入列表。
使用 ufw 时,为每个地址段添加一条规则。这些文件末尾没有换行符,所以循环必须显式处理最后一行,否则会悄无声息地漏掉一个地址段:
#!/usr/bin/env bash
set -euo pipefail
for list in ips-v4 ips-v6; do
curl -fsS "https://www.cloudflare.com/$list" |
while read -r cidr || [ -n "$cidr" ]; do
ufw allow proto tcp from "$cidr" to any port 80,443 comment 'cloudflare'
done
done
然后用 sudo ufw delete allow proto tcp from any to any port 80,443 删除原先对所有人开放的规则。使用 nftables 时,在同一个事务里刷新两个集合,因为 nft -f 要么整批生效,要么完全不生效。如果下载失败,set -e 会中止脚本,旧集合保持不变:
#!/usr/bin/env bash
set -euo pipefail
v4=$(curl -fsS https://www.cloudflare.com/ips-v4 | paste -sd, -)
v6=$(curl -fsS https://www.cloudflare.com/ips-v6 | paste -sd, -)
nft -f - <<EOF
flush set inet filter cloudflare_v4
flush set inet filter cloudflare_v6
add element inet filter cloudflare_v4 { $v4 }
add element inet filter cloudflare_v6 { $v6 }
EOF
在开机时于 nftables.service 之后运行它,并用 systemd 定时器每周运行一次。之后,Nginx 需要为同样的地址段配置 set_real_ip_from,并设置 real_ip_header CF-Connecting-IP;,否则日志和限流看到的都是 Cloudflare 的地址而不是访客地址。Nginx 与 Cloudflare 缓存指南介绍了 Web 服务器这一侧的配置。
IP 白名单只能证明连接来自 Cloudflare 的网络,不能证明它属于你的区域。Cloudflare 的源站保护指南指出白名单容易受到 IP 伪造的影响,并介绍了更强的方案:需要 Full 或 Full (strict) 加密模式的 Authenticated Origin Pulls,以及完全不需要入站 Web 端口的 Cloudflare Tunnel。
无人值守更新与内核重启
Ubuntu Server 默认安装 unattended-upgrades 并启用安全更新,自动更新指南对此有说明。请实际检查一下:
cat /etc/apt/apt.conf.d/20auto-upgrades
sudo unattended-upgrade --dry-run -v
该文件应包含 APT::Periodic::Update-Package-Lists "1"; 和 APT::Periodic::Unattended-Upgrade "1";。在 Debian 上,执行 sudo apt install unattended-upgrades 和 sudo dpkg-reconfigure -plow unattended-upgrades。
内核修复只有在重启后才会生效。在 Ubuntu 24.04 上,needrestart 会重启使用了已更新库的服务,但它无法替换正在运行的内核。当某个软件包需要重启时,会出现 /var/run/reboot-required 文件。把本地设置放进一个排序在软件包自带的 50unattended-upgrades 之后的文件里:
// /etc/apt/apt.conf.d/52unattended-upgrades-local
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:30";
选择流量低谷的时间,并确认应用在开机后无需人工干预就能恢复。日志位于 /var/log/unattended-upgrades/。Canonical Livepatch 对个人用途最多五台机器免费,可以在内存中修补严重和高危的内核漏洞,但 Canonical 明确表示它不能替代重启。
fail2ban 还是 CrowdSec
关闭密码登录之后,针对 SSH 的暴力破解不可能成功。封禁工具是第二层防护:它能减少日志噪音,节省握手消耗的 CPU,并拖慢扫描器。二者选其一即可。
fail2ban 在两个发行版中都有软件包,不需要注册账户。在 Ubuntu 24.04 上,该软件包默认启用 sshd jail,使用 systemd 日志后端和 nftables 动作;这种后端同样适合没有 auth.log 的 Debian。用 sudo apt install fail2ban 安装后,在本地文件而不是 jail.conf 中调整 jail:
# /etc/fail2ban/jail.d/sshd.local
[sshd]
enabled = true
backend = systemd
maxretry = 5
findtime = 10m
bantime = 1h
bantime.increment = true
执行 sudo systemctl restart fail2ban 使配置生效,并用 sudo fail2ban-client status sshd 查看 jail 状态。
CrowdSec 解析同样的日志,与社区共享封禁列表交换信号,并通过独立的处置组件执行决策。Ubuntu 24.04 自带的版本是 1.4.6,而 CrowdSec 安装指南要求使用厂商仓库中的更新版本。运行安装脚本之前请先阅读它:
curl -s https://install.crowdsec.net | sudo sh
sudo apt install crowdsec crowdsec-firewall-bouncer-nftables
sudo cscli collections list
sudo cscli decisions list
在 CDN 之后,Web 请求都来自 CDN 的地址。在主机防火墙上封禁 HTTP 攻击者,要么会封掉一个 Cloudflare 边缘节点,要么针对的是一个从不直接连接的地址。主机层的封禁只用于 SSH,HTTP 则交给 CDN 的 WAF 和限流规则。
用 systemd 为应用加沙箱
以独立的系统用户在 systemd 单元中运行后端,而不是放在 shell 里或用 nohup 启动,让 systemd 拿走进程用不到的一切。用 sudo useradd --system --no-create-home --shell /usr/sbin/nologin webapp 创建用户。下面这个单元在 Nginx 后面、于 localhost 上运行一个 Node.js 服务:
# /etc/systemd/system/webapp.service
[Unit]
Description=Example web application
After=network-online.target
Wants=network-online.target
[Service]
User=webapp
Group=webapp
WorkingDirectory=/opt/webapp
ExecStart=/usr/bin/node /opt/webapp/server.js
Environment=HOST=127.0.0.1 PORT=3000
LoadCredential=db_password:/etc/webapp/db_password
Restart=on-failure
StateDirectory=webapp
UMask=0077
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
CapabilityBoundingSet=
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
[Install]
WantedBy=multi-user.target
NoNewPrivileges=yes 阻止进程及其子进程通过 setuid 程序或文件 capabilities 获得新权限。ProtectSystem=strict 为该服务把整个文件系统挂载为只读,只有 /dev、/proc 和 /sys 例外,因此唯一可写的位置是由 StateDirectory= 创建的 /var/lib/webapp。其他需要写入的路径用 ReadWritePaths= 添加。ProtectHome=yes 隐藏 /home、/root 和 /run/user,PrivateTmp=yes 为服务提供独立的 /tmp 和 /var/tmp。空的 CapabilityBoundingSet= 会去掉所有 capabilities,而 @system-service 是 systemd.exec 手册推荐作为起点的系统调用白名单。
这里有意没有加入 MemoryDenyWriteExecute=yes:手册指出它与 JIT 引擎不兼容,而 Node.js 中的 V8 会在运行时生成机器码。对于用 Go、Rust 或 C 编写的服务,可以加上它并进行测试。
在搭载 systemd 255 的 Ubuntu 24.04 上,systemd-analyze security --offline=true 给这个文件的评分是 1.5,而只设置了 User= 的同一单元评分为 9.0。启动服务并检查:
sudo systemctl daemon-reload
sudo systemctl enable --now webapp
systemd-analyze security webapp.service
journalctl -u webapp -b -n 50 --no-pager
如果服务启动失败,日志通常会指出被拦截的路径或系统调用。每次只放宽一个选项,而不是删掉整段配置。Nginx 这类随发行版打包的服务以 root 身份启动,并需要特定的 capabilities,因此也要通过 sudo systemctl edit nginx 逐项加固。
时间同步、journald 与日志保留
TLS 校验、TOTP 验证码、备份计划和日志关联都依赖准确的时钟。Ubuntu 24.04 和 Debian 12 默认都使用 systemd-timesyncd。执行 timedatectl status,确认输出中有 System clock synchronized: yes 和 NTP service: active。如果没有任何 NTP 服务在运行,请安装 systemd-timesyncd 或 chrony。
在默认的 Storage=auto 下,只有 /var/log/journal 存在时日志才会保存在磁盘上,而且最多可占用文件系统的 10%,上限为 4 GB。请设置明确的上限,避免某个吵闹的服务塞满小磁盘,同时让保留期限与你的隐私政策一致:
# /etc/systemd/journald.conf.d/50-retention.conf
[Journal]
Storage=persistent
SystemMaxUse=1G
MaxRetentionSec=30day
用 sudo systemctl restart systemd-journald 应用配置,再用 journalctl --disk-usage 检查占用。/var/log 中的 Nginx 和应用日志由 logrotate 轮转,所以要让 /etc/logrotate.d/ 的保留期限与之保持一致。Debian 12 发行说明确认默认不再安装 rsyslog,因此在 Debian 上 systemd 日志就是唯一的系统日志。访问日志包含 IP 地址,而在 GDPR 下 IP 地址可能属于个人数据,所以更短的保留期往往才是正确的选择。
密钥与文件权限
VPS 上的敏感信息通常是从再普通不过的地方泄露的:所有用户都能读取的 .env 文件、Git 历史中的密钥,或者被打印进崩溃报告的环境变量。systemd 手册明确指出,环境变量不适合传递机密,因为它们会通过 D-Bus 暴露给非特权客户端,并被子进程继承。请像上面的单元那样使用 LoadCredential=。源文件只有 root 可读,服务则在 $CREDENTIALS_DIRECTORY 下获得一份只读副本:
sudo install -d -m 0700 -o root -g root /etc/webapp
sudo install -m 0600 -o root -g root /dev/null /etc/webapp/db_password
sudoedit /etc/webapp/db_password
systemd-creds encrypt 配合 LoadCredentialEncrypted=,还能用主机密钥或 TPM 对静态存储的文件加密。应用代码应归 root 或部署用户所有,而不是归服务用户所有,这样被攻破的进程就无法改写自身。安装第三方软件后,检查所有人可写的文件和意料之外的 setuid 程序:
sudo find / -xdev -type f -perm -0002 -print
sudo find / -xdev -type f -perm -4000 -print
CI 部署密钥应在 authorized_keys 中单独占一行,加上 restrict 选项,并尽可能附带 from= 地址列表。在服务器上执行命令的编程智能体也需要同样的纪律:独立用户、不接触生产环境机密、破坏性命令需要审批。智能体编程指南比较了当前各款编程 CLI 的权限控制。
备份与监控钩子
加固降低事故发生的概率,而备份和告警决定事故会持续多久。服务商快照适合快速回滚,但它们和服务器在同一个账户里,所以算不上独立副本。请在服务商之外保存一份加密且带版本的副本,并实际测试恢复。使用 restic 的 3-2-1 备份指南介绍了仓库布局、保留策略和恢复演练。
单台 VPS 只需要几个信号:一个经由 CDN 的外部 HTTP 检查,一个确认源站拒绝直连的检查,磁盘使用率,证书到期时间,失败的 systemd 单元,以及每个定时任务的心跳,这样悄悄停掉的备份也会触发告警。systemd 可以在任何单元失败时调用通知程序:
# /etc/systemd/system/[email protected]
[Unit]
Description=Alert on failure of %i
[Service]
Type=oneshot
ExecStart=/usr/local/bin/notify-failure %i
在应用和备份单元的 [Unit] 段中加入 OnFailure=notify-failure@%n.service。脚本可以把单元名称和最近几行日志发送到聊天工具或值班系统的 webhook。Prometheus node exporter 这类指标导出器应只监听 localhost 或私有网络。如果服务器上运行着拥有 shell 权限的 AI 智能体或 MCP 工具,要给它们单独的用户和沙箱,并把它们的输入视为不可信数据,提示注入与 MCP 安全指南对此有详细说明。
第一小时检查清单
- 安装更新,创建具名的 sudo 用户,并保持服务商控制台处于打开状态。
- 安装 Ed25519 密钥,添加禁用 root 登录和密码登录的
00-hardening.conf。 - 执行
sshd -t和sshd -T,重启ssh,在关闭旧会话前先测试新会话。 - 为 IPv4 和 IPv6 启用默认拒绝的防火墙,只放行 SSH、80 和 443。
- 把 Web 端口限制为 Cloudflare 地址段,或改用 Authenticated Origin Pulls 或 Tunnel,并在 Nginx 中还原访客真实 IP。
- 确认无人值守安全更新已生效,并安排自动重启。
- 为 SSH 启用 fail2ban 或 CrowdSec,HTTP 则使用 CDN 的 WAF。
- 以独立用户在带沙箱的 systemd 单元中运行应用,并检查
systemd-analyze security的结果。 - 确认时间同步正常,并设置日志大小和保留期限。
- 把机密移到只有 root 可读的 credentials 文件中,并检查文件权限。
- 配置服务商之外的备份,测试一次恢复,并加上失败告警和心跳。
- 主动重启一次,确认所有服务无需人工干预即可恢复。