树莓派插上网线就能连:给 eth0 加 link-local 回退

eth0 只配了 dhcp4 时,遇到不发地址的对端就彻底连不上。加一份 netplan 覆盖文件:DHCP 优先,失败约 10 秒后自动补 169.254,另留一个永久锚点。文末是两个幂等脚本。

起因

朋友给了一台树莓派。我先把电脑开成一个同名热点,想让 Pi 自己连上来,没成。改用网线直连,还是有问题。于是有了这套调研。

这台树莓派 5(Ubuntu 24.04)原来只有一个 `dhcp4: true`:谁来发地址,谁就是单点故障。

中间接个交换机、或者换成一台默认不发地址的电脑直连,就拿不到 IPv4,只剩 mDNS 和 IPv6 那两条路可走。

现在的目标是插上网线就能连:

```bash
ssh pi          # 等价于 ssh pi@pi.local
```

下面把主机名与用户名都写成 `pi`,命令里换成你自己的即可。

先决条件

这套做法在什么环境上验证过

```plaintext
树莓派 5 | Ubuntu 24.04 | systemd 255(netplan → systemd-networkd)
客户端:CachyOS(Arch)—— 下面所有通路都是在这一台机器上测的
```

只在这一个组合上实测过。 下面这些地方会因环境而异:

一、以前与现在的差别

改动只有一件:给 eth0 加了 IPv4 link-local 回退,外加一个永久的固定锚点。

二、现在同时具备的四条通路

名字那条路是 `pi.local`(avahi / mDNS),它会同时发布上面所有地址,地址变了自动跟着变。所以平时只需要记住 `ssh pi`。

提示

第四条通路的地址由 MAC 派生,换网卡或换硬件就变;`pi.local` 不受影响。 网络里如果出现第二台同名机器,avahi 会把它改名成 `pi-2.local`。

三、Pi 上改了什么

新增的文件只有一个,`/etc/netplan/70-eth0-fallback.yaml`:

```yaml
# 让 eth0 在有/没有 DHCP 服务器时都能连
# 优先级:DHCP(对端有服务器时,比如电脑共享或家里路由器)> 固定锚点 192.168.77.1 > IPv4 link-local 169.254.x.x
network:
  version: 2
  ethernets:
    eth0:
      dhcp4: true
      optional: true
      # 同时开启 IPv4/IPv6 链路本地地址,完全不依赖任何服务器
      link-local: [ipv4, ipv6]
      # 固定锚点地址:给不支持 mDNS 的客户端手工直连用
      addresses:
        - 192.168.77.1/24
```

原有的 `/etc/netplan/50-cloud-init.yaml` 没动,改之前备份了一份到 `/root/`。

应用方式:

```bash
sudo netplan generate && sudo netplan apply
```

注意这套网络栈是 netplan → systemd-networkd,eth0 在 NetworkManager 里是 `unmanaged`,所以用 `nmcli` 改是没有用的。

为什么 DHCP 正常时看不到 169.254

systemd 255 的 man 页写得很直接:

An IPv4 link-local address is configured when `yes` or `ipv4` and when DHCPv4 autoconfiguration has been unsuccessful for some time.

有 DHCP 就优先用 DHCP,失败才补 IPv4LL。这正是想要的语义:多一条兜底,不改动正常情况下的行为。

实测(用一对 veth 造了一条没有 DHCP 服务器的链路,不碰 eth0):

四、两个脚本

上面那份配置手写也就十行,但「备份 → 写入 → 校验 → 应用 → 验证」这套动作值得脚本化,另外脚本里加了一道自动回滚保险。两个脚本都是幂等的,重复跑安全。

`setup-pi.sh`:在 Pi 上跑

它会先把 `/etc/netplan/` 里现有的配置备份到 `/root/`(带时间戳,不覆盖旧备份),再写新文件、`netplan generate` 校验语法,然后挂一个 180 秒的自动回滚定时器才应用 —— 如果新配置把网络弄断了,180 秒后它会自己删掉新文件并恢复。

```bash
# 在 Pi 上(或 ssh 进去)
ssh pi@pi.local 'sudo bash -s' < setup-pi.sh
```
```bash
#!/usr/bin/env bash
# 在树莓派上运行(或 ssh 进去运行):让 eth0 在没有 DHCP 服务器时也能连上。
#
#   用法: ssh pi@pi.local 'sudo bash -s' < setup-pi.sh
#   幂等:重复运行安全;自带 180 秒自动回滚保险。
#
# 目的:不再依赖"某一台电脑负责发地址"。插上网线后,无论对端是
#   - 家里路由器(有 DHCP)        -> 拿路由器分配的地址
#   - 一台开着网络共享的电脑        -> 拿电脑分配的地址
#   - 交换机 / 另一台什么都没配的电脑 -> 自动上 169.254.x.x(IPv4 link-local)
#   - 永远都在                    -> 固定锚点 192.168.77.1 + IPv6 链路本地 fe80::
# 四种情况都能连。

set -euo pipefail

CONF=/etc/netplan/70-eth0-fallback.yaml
ANCHOR=192.168.77.1/24
STAMP=$(date +%F-%H%M%S)

[[ $EUID -eq 0 ]] || { echo "请用 sudo 运行:sudo bash $0" >&2; exit 1; }

echo "==> 1/5 备份现有 netplan 到 /root/"
for f in /etc/netplan/*.yaml; do
  [[ $f == "$CONF" ]] && continue
  dest="/root/$(basename "$f").bak-$STAMP"
  [[ -e $dest ]] || cp -a "$f" "$dest"   # 不覆盖已有备份
done
ls -1t /root/*.bak-* 2>/dev/null | head -5

echo "==> 2/5 写入 $CONF"
cat > "$CONF" <<'YAML'
# 由 setup-pi.sh 生成:让 eth0 在有/没有 DHCP 服务器时都能连
# 优先级:DHCP > 固定锚点 192.168.77.1 > IPv4 link-local 169.254.x.x
network:
  version: 2
  ethernets:
    eth0:
      dhcp4: true
      optional: true
      link-local: [ipv4, ipv6]
      addresses:
        - 192.168.77.1/24
YAML
chmod 600 "$CONF"

echo "==> 3/5 校验语法(失败则不动任何东西)"
netplan generate
echo "    语法 OK"

echo "==> 4/5 挂 180 秒自动回滚保险,然后应用"
systemd-run --on-active=180 --unit=netplan-rollback \
  /bin/bash -c "rm -f $CONF; netplan apply" >/dev/null 2>&1 || true
netplan apply
sleep 8

echo "==> 5/5 验证"
ip -brief addr show eth0 || true

if ip -4 addr show eth0 | grep -q "${ANCHOR%/*}"; then
  systemctl stop netplan-rollback.timer 2>/dev/null || true
  systemctl reset-failed netplan-rollback.timer netplan-rollback.service 2>/dev/null || true
  echo
  echo "✅ 成功:锚点 ${ANCHOR%/*} 已生效,回滚保险已取消。"
  echo "   现在这些通路都可用(mDNS 名称 pi.local 会同时发布它们):"
  echo "     - DHCP 地址        :有 DHCP 服务器时自动获得"
  echo "     - 固定锚点         :${ANCHOR%/*}  (客户端配 192.168.77.2/24 即可直连)"
  echo "     - IPv4 link-local  :169.254.x.x  (无 DHCP 服务器时约 10 秒后自动获得)"
  echo "     - IPv6 link-local  :fe80::...    (一直都在,无需任何服务器)"
  exit 0
else
  echo "⚠️  没看到锚点地址,180 秒后会按原配置自动回滚。" >&2
  exit 1
fi
```

`setup-client.sh`:在要连 Pi 的那台电脑上跑

不需要 `sudo`,不改系统配置,只往 `~/.ssh/config` 写两个别名(`pi` 走 mDNS、`pi-anchor` 走固定锚点),然后按优先级探测一遍,告诉你哪条通路通了。

```bash
bash setup-client.sh
```

⚠️ 跑之前先把脚本开头那两行改成你自己的主机名与用户名(`PI_NAME` / `PI_USER`,示例值是 `pi.local` 与 `pi`)。

```bash
#!/usr/bin/env bash
# 在一台新电脑上运行(Linux / macOS),配好连接树莓派所需的一切。
#
#   用法: bash setup-client.sh
#   不需要 sudo,不改系统配置,只写 ~/.ssh/config。
#
# ⚠️ 先改下面两行:主机名与用户名换成你自己的(下面是示例值)。
#
# 做的事:
#   1. 检查本机 mDNS(.local 域名)解析能力
#   2. 没有 SSH 密钥就生成一个
#   3. 往 ~/.ssh/config 写 pi / pi-anchor 两个别名
#   4. 按优先级探测树莓派,并说清哪条通路通了、哪条没通

set -uo pipefail

PI_NAME="pi.local"
PI_USER="pi"
PI_ANCHOR="192.168.77.1"
PI_ANCHOR_NET="192.168.77"
ANCHOR_CLIENT_IP="192.168.77.2"
KEY="$HOME/.ssh/id_ed25519"
CFG="$HOME/.ssh/config"

ok()   { printf '  \033[32m✅ %s\033[0m\n' "$*"; }
bad()  { printf '  \033[31m❌ %s\033[0m\n' "$*"; }
warn() { printf '  \033[33m⚠️  %s\033[0m\n' "$*"; }
step() { printf '\n==> %s\n' "$*"; }

OS=$(uname -s)
step "环境"
echo "  系统: $OS $(uname -r)"

# ---------- 1. mDNS ----------
step "1/4 检查 mDNS(.local 域名解析)"
case "$OS" in
  Darwin)
    ok "macOS 自带 Bonjour,$PI_NAME 可直接用" ;;
  Linux)
    if getent hosts "$PI_NAME" >/dev/null 2>&1; then
      ok "已能解析 $PI_NAME -> $(getent hosts "$PI_NAME" | awk '{print $1}' | tr '\n' ' ')"
    elif systemctl is-active --quiet avahi-daemon 2>/dev/null; then
      warn "avahi 在跑,但暂时解析不到 $PI_NAME(Pi 可能没插线/没开机)"
    else
      bad "本机缺少 mDNS 支持,$PI_NAME 解析不了"
      echo "     装一下(Debian/Ubuntu): sudo apt install libnss-mdns avahi-daemon"
      echo "     或者退回固定锚点: 见下面 pi-anchor 别名"
    fi ;;
  *)
    warn "未知系统,跳过 mDNS 检查" ;;
esac

# ---------- 2. 密钥 ----------
step "2/4 检查 SSH 密钥"
mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"
if [[ -f "$KEY" ]]; then
  ok "已有 $KEY"
else
  if ssh-keygen -t ed25519 -N '' -f "$KEY" >/dev/null 2>&1; then
    ok "已生成新密钥 $KEY"
    warn "这是新电脑的新密钥:Pi 上默认没有它。两种办法登录——"
    echo "       (a) 用密码登录一次,然后 ssh-copy-id $PI_USER@$PI_NAME"
    echo "       (b) 直接把老电脑的 $KEY 拷过来"
  else
    bad "密钥生成失败,请手动 ssh-keygen -t ed25519"
  fi
fi

# ---------- 3. ssh config ----------
step "3/4 写入 ~/.ssh/config"
if grep -qE "^Host\s+.*\bpi\b" "$CFG" 2>/dev/null; then
  ok "已存在 pi 相关配置,跳过(没动你的文件)"
else
  {
    echo ""
    echo "# 连树莓派用的 SSH 别名 —— 由 setup-client.sh 添加"
    echo "# 用法: ssh pi        (走 mDNS 名称,换网线/换地址都自动跟随)"
    echo "#      ssh pi-anchor (走固定锚点,需要本机有 192.168.77.x 地址)"
    echo "Host pi $PI_NAME"
    echo "    HostName $PI_NAME"
    echo "    User $PI_USER"
    echo "    IdentityFile ~/.ssh/id_ed25519"
    echo "    IdentitiesOnly yes"
    echo "    ServerAliveInterval 30"
    echo "    StrictHostKeyChecking accept-new"
    echo ""
    echo "Host pi-anchor"
    echo "    HostName $PI_ANCHOR"
    echo "    User $PI_USER"
    echo "    IdentityFile ~/.ssh/id_ed25519"
    echo "    IdentitiesOnly yes"
    echo "    StrictHostKeyChecking accept-new"
  } >> "$CFG"
  chmod 600 "$CFG"
  ok "已写入 $CFG(pi / pi-anchor)"
fi

# ---------- 4. 探测 ----------
step "4/4 探测树莓派"
try_ssh() {
  ssh -o BatchMode=yes -o ConnectTimeout=6 -o StrictHostKeyChecking=accept-new \
      "$PI_USER@$1" 'echo $(hostname)' 2>/dev/null
}

CONNECTED=""
if H=$(try_ssh "$PI_NAME") && [[ -n "$H" ]]; then
  ok "mDNS 通路可用:ssh $PI_USER@$PI_NAME  (主机名 $H)"
  CONNECTED=1
else
  bad "mDNS 通路不通:$PI_USER@$PI_NAME"
fi

if [[ -z "$CONNECTED" ]]; then
  if ip -4 -o addr 2>/dev/null | grep -q "$PI_ANCHOR_NET\." || \
     ifconfig 2>/dev/null | grep -q "inet $PI_ANCHOR_NET\."; then
    if H=$(try_ssh "$PI_ANCHOR") && [[ -n "$H" ]]; then
      ok "锚点通路可用:ssh $PI_USER@$PI_ANCHOR  (主机名 $H)"
      CONNECTED=1
    else
      bad "锚点通路不通:$PI_USER@$PI_ANCHOR"
    fi
  else
    warn "本机没有 $PI_ANCHOR_NET.x 地址,锚点通路用不了"
    echo "     想用它就手工加一个地址(需要管理员权限):"
    case "$OS" in
      Darwin) echo "       sudo ifconfig \$(route -n get default | awk '/interface/{print \$2}') alias $ANCHOR_CLIENT_IP netmask 255.255.255.0" ;;
      *)      echo "       sudo ip addr add $ANCHOR_CLIENT_IP/24 dev <你的网卡名>" ;;
    esac
  fi
fi

if [[ -z "$CONNECTED" ]]; then
  echo
  warn "两条路都不通。快速排查:"
  echo "     · 网线插好了吗?Pi 的网口灯亮吗?(Pi 5 千兆口支持任意网线,不需要交叉线)"
  echo "     · 看本机邻居表里有没有可疑的 Pi:"
  case "$OS" in
    Darwin) echo "         arp -a | grep -E '169\\.254|$PI_ANCHOR_NET|10\\.42\\.'" ;;
    *)      echo "         ip neigh | grep -E '169\\.254|$PI_ANCHOR_NET|10\\.42\\.'" ;;
  esac
  echo "     · 找不到就用 IPv6 广播 ping 整条链路:"
  echo "         ping6 -c3 ff02::1%<你的网卡名>   # macOS/Linux"
  echo "     · 还是没有:Pi 没开机,或者它卡在启动 / 别的问题"
  exit 2
fi

echo
ok "搞定。以后一条命令: ssh pi"
```

五、重启验证

断电重启是这个方案里没法在运行中验证的一环,所以单独测了一次:

重启耗时:kernel `5.357s` + userspace `44.844s`,掉线到 SSH 恢复大约 30 秒。

结论:配置是持久的,拔电、换线、换电脑都不需要再动 Pi。

六、换一台新电脑怎么连

跑一遍 `setup-client.sh`(见上一节)。手动做的话是这三步:

```bash
# 1) 先看通路在不在
ping -c1 pi.local
arp -a | grep -E '169\.254|192\.168\.77|10\.42\.'    # .local 不通时用这个找地址

# 2) 新电脑的公钥还没装到 Pi 上时,先用密码登录一次
ssh-copy-id pi@pi.local

# 3) 写 ssh 别名,以后直接用 ssh pi
cat >> ~/.ssh/config <<'EOF'
Host pi
    HostName pi.local
    User pi
EOF
```

第三步里 `HostName` 用 `pi.local`;如果这台电脑解析不了 `.local`,把它改成 `192.168.77.1`(网卡先设静态 `192.168.77.2/24`)即可。

`.local` 解析需要 mDNS:Debian / Ubuntu 上 `sudo apt install libnss-mdns avahi-daemon`。Ubuntu 22.04 之后的 systemd-resolved 通常也能直接解析。装不了就用锚点地址。

Windows 10 / 11 自带 mDNS 解析和 OpenSSH,基本开箱可用:

```powershell
# 1) 探测
ping pi.local
ssh pi@pi.local                # 首次问指纹,输 yes;没有密钥就用密码
# 2) 万一 .local 不通,从邻居表里找地址
arp -a | findstr "169.254 192.168.77 10.42."
# 3) 写别名:%USERPROFILE%\.ssh\config
```

实在解析不了 `pi.local` 时,给网卡设静态地址 `192.168.77.2 / 255.255.255.0`,然后 `ssh pi@192.168.77.1`。

七、连不上时的排查顺序

```bash
# ① 名字能不能解析(能解析说明 avahi 在,只是路可能不通)
avahi-resolve -n pi.local

# ② 邻居表里找 Pi:三条通路各自的网段
ip neigh | grep -E '169\.254|192\.168\.77|10\.42\.'     # Linux
arp -a                                                  # macOS / Windows

# ③ 整条链路 IPv6 广播 ping,最底层的"有人吗"
ping6 -c3 ff02::1%<你的网卡名>        # 会列出链路上所有 IPv6 主机

# ④ mDNS 彻底不通时,用锚点(客户端先配 192.168.77.2/24)
ssh pi@192.168.77.1
```

网口灯不亮就是物理层问题,先换线换口。

实测遇到过一次 `ssh: Could not resolve hostname pi.local: Temporary failure in name resolution`。 那是客户端 mDNS 解析的瞬时抖动,当时 Pi 完全正常(直连 IP 与 IPv6 广播 ping 都通),重试一次或者直接用 IP 连就好。 mDNS 只是方便,不是非它不可 —— 这也是锚点地址一直留着的理由。

八、几条运维备注

九、怎么撤销

```bash
sudo rm -f /etc/netplan/70-eth0-fallback.yaml && sudo netplan apply

# 需要的话再还原云初始化的那份
sudo cp /root/50-cloud-init.yaml.bak /etc/netplan/50-cloud-init.yaml
```

参考