记录使用 Mac Mini M4 配置个人服务器的全过程。

由于正在使用的个人服务器 (Github Education 薅来的一年 Digital Ocean) 即将在 2025年6月17日到期,所以,我决定将其迁移到 Mac Mini 上。

Personal needs

  • Services, Development & Automation (Docker 或 OrbStack 运行和管理容器化应用程序)
    • ✅ 电子书服务器:Calibre Content Server
    • 使用 Docker 运行个人日常所需要的服务(目前包括 RSSHub, miniflux等)
      • 将原云服务器上的数据迁移到 Mac mini
    • Mercari-bot 迁移 + CI/CD工作流配置
    • GitHub Actions Runner,本地执行 CI/CD 作业
    • 托管个人博客或其他 Web 应用
    • Local LLM
  • Monitoring & Maintenance
    • ✅ 远程硬件监控服务器的性能指标(CPU、RAM、温度等). See Basic Setup.
    • Docker Container 开源监控管理工具选型和配置
    • 日志管理平台
  • Network
  • Remote Access
    • ✅ 远程访问 Mac mini 服务器,例如通过 Mac 自带的 Screen Sharing 和 Remote Login (SSH). See Basic Setup.
    • ✅ 在不连接显示器的情况下运行服务器 (headless 模式),可能需要 HDMI 虚拟插头. See Basic Setup.

01 Basic Setup

  1. 卸载所有不需要的软件,如 Apple 自带的 GarageBand, Numbers 等
  2. 在 MacBook, iOS, Mac mini 上安装和配置 Tailscale
  3. Remote Access: Screen Sharing, Remote Login (SSH): 在 Mac mini 中的 General -> Sharing 中开启,然后使用 MBP 的 Screen Sharing 和 Terminal (SSH) 连接到 Mac mini.
  4. Energy 设置 – 目标是让 Mac mini 持续运行不睡眠
    • System Settings -> Energy -> turn on all following options
      • Wake for network access
      • Low Power Mode
      • Prevent automatic sleeping when the display is off
  5. Mac mini 虚拟屏幕 – 买了一个屏幕欺骗器 (HDMI Dummy Plug),让 Mac mini 能够无头模式 (headless) 运行
  6. Homebrew 包管理工具安装
    • /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
    • 然后根据提示运行命令
  7. CPU / GPU / Network / etc. remote monitor: Stat > Stats Remote Monitor

02 Network

在 MacOS 上同时运行网络工具和 Tailscale

🙋 The Problem

MacOS 不同于 IOS,允许用户同时运行多个网络工具,如 Tailscale 和代理工具 (⭕️X/🚀) 。然而,这种灵活性也带来了路由冲突的问题:多个工具可能试图控制相同的 IP 地址段或网络接口,导致像 Tailscale 这样的虚拟网络流量无法正确路由。即,Tailscale 中显示设备已经 Connected,但是在 所示的情况下,无法 ping 通 Tailscale 网络中的其他设备 IP 地址。

MacOS允许同时使用多个网络代理工具
MacOS允许同时使用多个网络代理工具

我趁此梳理了 macOS 系统中 CIDR 路由的优先级规则、⭕️X/🚀 等代理工具的 Excluded Routes 和 Skip Proxy 配置的实际行为,以及如何通过路由掩码精度(如 /9 vs /10)调整优先级,实现在多个工具共存时 Tailscale 的正常工作。

🔎 Identify the Issue

🚀 配置项

如果想确保某些内网地址能够绕过 🚀 的 TUN 接口处理,避免路由被劫持或覆盖,需要将这些地址段加入 TUN Excluded Routes,这样系统会查询系统的 route table (使用命令 netstat -rn 查看),然后按照优先级,判断使用的虚拟网卡。如果 Tailscale 的 route 优先级高于 🚀 的 route,则会使用 utunX 接口进行连接,而不会被 Shadowrocket 截获。

相反,如果希望某些 App 的流量不经过代理服务器但仍由 Shadowrocket 处理(例如用于记录、限速或连接控制),可以将其 IP 或域名添加至 Skip Proxy 列表中。

两者的区别和使用场景详见 ,其他参数及TUN模式的解释见 🚀通用参数。一个详细的案例见后续的Tailscale 访问 Calibre Content Server

🚀 Skip Proxy v.s. TUN Excluded Routes
Skip ProxyTUN Excluded Routes
用途不通过HTTP代理服务器,而是直接由🚀的TUN接口 (从网络链路层接管所有流量) 转发的域名或IP段。不经过TUN接口转发,而是走系统默认路由 (即查询系统route tables)
是否通过 TUN 接口?✅ 是❌ 否
适合场景App 不需要代理,但仍希望 🚀 控制连接(如限速、日志)希望完全绕过🚀(比如局域网、Tailscale), 查询系统的 route table
是否经过 🚀?✅ 是(通过 TUN 拦截)取决于系统的 route table
🚀 会接触这个流量吗?✅ 会处理后直连取决于系统的 route table

Tailscale 保留网段

Tailscale 分配的默认地址范围为 100.64.0.0/10 地址段,即 100.64.0.0 ~ 100.127.255.255,见Tailscale-assigned IP range,例如:

100.9x.xx.xx
100.1xx.xx.xx
100.7x.xx.xx

所以,将 100.64.0.0/10 加入 🚀的 TUN Excluded Routes 中,按理即可确保 Tailscale 的流量不会被 🚀 劫持。但是,100.64.0.0/10 已经在 🚀 默认的 excluded routes 中,但此时仍然无法 ping 通 Tailscale 网络中的其他设备 IP 地址。

✅ The Solution

参考:macos 下的 stash 和 tailscale 不能一起使用? 里面的回答,

Tailscale 用了一个特殊的虚拟网段 100.64.0.0/10,让它的流量通过它自己的虚拟接口(utunX)来走。 Quantumult X 也会设置一些“排除路由”(excluded routes),但它会把这些流量指定到默认网卡接口,比如 en0(WIFI 接口)。

也就是说,🚀的情况可能也和QX类似,其 excluded routes 的方式是——“往系统路由表添加一条 route,指向系统默认接口(如 en0),这样,Tailscale 的流量还是被劫持了,不再走 Tailscale 自己的虚拟网卡接口 utunX

(base) ➜ route get 100.64.0.1
   route to: 100.64.0.1
   interface: en0

那么,为什么会“覆盖”?因为在 macOS/iOS 的网络路由系统中,路由优先级是按 更精确(更小的 CIDR,比如 /24) 优先。即 CIDR 越小,匹配范围就越大,粒度就越粗,优先级就越低 ()。如果 CIDR 一样,系统可能就按照顺序优先来匹配先添加的那条路由,这样可能就造成另一条路由失效。

所以,将🚀 excluded routes 的100.64.0.0的CIDR变小(即匹配范围变大、优先级变低),就能让 Tailscale 的路由优先匹配。尝试修改 100.64.0.0/10100.64.0.0/8 可以立马解决 Tailscale 的路由被覆盖的问题:

(base) ➜ route get 100.64.0.1
   route to: 100.64.0.1
   interface: utun9

p.s. 修改为 /9 时仍然被覆盖,暂时不知道为什么…

补充:CIDR

CIDR 全称是 Classless Inter-Domain Routing,无类域间路由。其表达式为 IP/位数,例如 100.64.0.0/10。是一种 IP 地址+斜杠数字的写法,用来表示某个 IP 网段的范围。其中,/10是指,前 10 位是网络地址,剩下的位数是主机地址。对于 IPv4 来说,IP 地址是 32 位的,所以 100.64.0.0/10 表示前 10 位是网络地址,剩下的 $32-10=22$ 位是主机地址,也就是,前 10 位固定,后面有 22 位可变。即:

/x的x越小,固定位数越小,可变位越多,IP 网段的范围就越大,粒度就越粗,在 Mac 路由表中的优先级就越低。

CIDR 优先级
CIDR固定位数可变位数IP数匹配范围粒度优先级
/9932-9=23$2^{23}$ = 8,388,608
/101032-10=22$2^{22}$ = 4,194,304

🚥 路由匹配规则:系统在决定一条流量走哪条路由时,会优先选择 CIDR 更“具体”的那一条路由(也就是斜杠后面的数字更大)

题外话,复习一下如何计算100.64.0.0/10 的起始和终止地址 ──

  1. 将 IP 地址转换为二进制
  2. 计算起始(主机位全0)和终止(主机位全1)地址
  3. 将二进制转换为十进制

03 Services, Development & Automation

Calibre Content Server

  • 在 Mac mini 上安装 Calibre
  • Airdrop MBP 上的 Calibre Database 到 Mac mini
  • Calibre 自带的 Content Server – 已经满足需求
  • 在 Mac mini 上安装 Calibre-Web
  • 试用看功能能否满足需求 (Highlight 同步)
  • 如果不能,卸载 Calibre-Web,仅使用 Content Server

Mac mini 上 Calibre Content Server 无法在其他设备通过 Tailscale 访问

🙋 The Problem

在 Mac mini 上启动 Calibre 的 Content Server,使用默认或自定义端口(如 8081)时:

  • ✅ 在本机浏览器使用 localhost:8081 能正常访问
  • ❌ 在另一台通过 Tailscale 连接的 MBP 上
    • 🌐浏览器访问 http://<tailscale-ip>:8081 无法访问(超时/503)
    • 💻命令行访问 http://<tailscale-ip>:8081 能正常访问 (curl or ping ip

此时 MBP 设置情况如下:

🚀🚀 TUN mode🚀 TUN Excluded Routes🚀 Skip Proxy🚀 Proxy rule listTailscale系统代理
onoff100.64.0.0/8🈚️ TS 网段🈚️ TS IP 相关规则onhttp_proxy, https_proxy, all_proxy 均设置为🚀代理 127.0.0.1:1082

🔎 Debug

首先,查看🚀的logs …
浏览器请求  -- 代理接口接管 -- 查找 proxy rule list
[13:00:39.672] proxy stream <51> lookup host => 100.xxx.xxx.xxx:8081
[13:00:39.810] proxy stream <46> disconnect gateway.icloud.com:443 error => Attempt to connect to host timed out reason => none cost => 5012.787104 ms
[13:00:39.811] chain socket <46> remove closed socket => 2403:300:1366::2:5:443
[13:00:39.811] proxy stream <46> remove host => gateway.icloud.com port => 443 total => 19
[13:00:40.044] proxy stream <52> loopback => 1 total => 20
[13:00:40.096] proxy stream <52> async lookup rule url => http://100.xxx.xxx.xxx:8081/ host => 100.xxx.xxx.xxx port => 8081
[13:00:40.097] proxy stream <52> tcp rule => {
    result = "IP-CIDR,100.xxx.xxx.xxx/8,DIRECT,no-resolve";
    type = DIRECT;
    ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15";
    url = "http://100.xxx.xxx.xxx:8081/";
}
[13:00:40.097] proxy stream <52> lookup host => 100.xxx.xxx.xxx:8081
[13:09:00.557] proxy stream <47> disconnect http://100.xxx.xxx.xxx:8081/ error > Attempt to connect to host timed out reason => none cost => 5010.002017 ms
[13:09:00.557] chain socket <47> remove closed socket => 100.xxx.xxx.xxx:8081
[13:09:00.557] proxy stream <47> remove host => 100.xxx.xxx.xxx port => 8081 total => 13

可以看出,浏览器请求的TS地址被🚀的代理接口接管,然后被proxy rule list命中,直接返回了DIRECT,即🚀直接发出访问,没有到TS的虚拟网卡接口。

而在Terminal ping/curl 请求 google or ts 网段 时, 🚀 的 logs 里没有相关输出。于是在命令行查看两个地址经过的 route:

route get…
(base) ➜  ~ route get www.google.com
   route to: 31.13.112.9
destination: 31.13.112.9
  interface: utun4      # 🚀的TUN接口

(base) ➜  ~ ifconfig utun4
utun4: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 4064
	inet 198.xx.x.x --> 198.xx.x.x netmask 0xffffff00 # 🚀的TUN接口

(base) ➜  ~ route get 100.xxx.xxx.xxx
   route to: mini.xxxxxx.ts.net
destination: 100.64.0.0
       mask: 255.192.0.0
  interface: utun9    # Tailscale 的虚拟网卡接口

(base) ➜  ~ ifconfig utun9
utun9: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 1280
	inet 100.1xx.xx.xx --> 100.1xx.xx.xx netmask 0xffffffff # Tailscale 的虚拟网卡接口
	inet6 fd7a:115c:a1e0::5001:4b37 prefixlen 48
	nd6 options=201<PERFORMNUD,DAD>

说明在命令行中使用 curl or ping 时,直接查询系统的 route table,而没有到 🚀的代理接口查询 proxy rule list。

再次验证猜想…
(base) ➜  ~ proxy
(base) ➜  ~ echo $all_proxy
socks5://127.0.0.1:1082

(base) ➜  ~ unproxy
(base) ➜  ~ echo $all_proxy
(base) ➜  ~ ping www.google.com
PING www.google.com (31.13.94.10): 56 data bytes
64 bytes from 31.13.94.10: icmp_seq=0 ttl=64 time=0.784 ms
64 bytes from 31.13.94.10: icmp_seq=1 ttl=64 time=0.474 ms
^C
--- www.google.com ping statistics ---
2 packets transmitted, 2 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 0.474/0.629/0.784/0.155 ms

确定了命令行没有走设置的 all_proxy 或者 http_proxy 代理,而是直接查询系统的 route table。

查看系统的 route table…
<!-- ------------------ when 🚀 turned on (TUN mode off) ------------------- -->
(base) ➜  ~ netstat -rn
Routing tables

Internet:
Destination        Gateway            Flags               Netif Expire
default            link#38            UCSg                utun4         #🚀TUN接口,default —— 代表所有目标地址(0.0.0.0/0),即这条路由为默认路由(default route)
default            10.xxx.xxx.xxx     UGScIg                en0
...
100.64/10          link#37            UCS                 utun9         #Tailscale虚拟网卡接口

<!-- ------------------------- when 🚀 turned off -------------------------- -->
(base) ➜  ~ netstat -rn | grep utun4 # 无输出

说明只要🚀打开,不论是否开启 TUN 模式,都会其TUN虚拟网卡接口添加为系统的默认路由

参考🚀的文档…

跳过代理(skip-proxy):跳过代理接口,使用 TUN 接口接管。此选项强制列表中的域名或 IP 的连接范围交由 Shadowrocket TUN 接口 来处理,而不是 Shadowrocket 代理接口。此选项用于提高部分应用程序对于代理环境的兼容性。若开启 TUN 模式 时,将强制使用 TUN 接管所有连接,此处地址列表可以忽略

TUN旁路路由(tun-excluded-routes):Shadowrocket TUN 接口 只能处理 TCP 协议。使用此选项可以绕过指定的 IP 范围,让其他协议通过。TUN (Network TUNnel device) 是 一种虚拟网络接口驱动(虚拟网卡),用于在用户程序内核网络栈之间传递 IP 层的数据包。其工作层为第三层 (IP层),常用用于 VPN、代理、分流、透明代理等场景。macOS 下接口名一般为 utunX(如 utun2, utun5)。有些软件不遵循系统代理,如终端、iTerm、Infuse。TUN 模式就是为了解决这个问题的,它对于不遵循系统代理的软件,它可以接管其流量并交由代理软件处理。

  • 补充:对于🚀,关闭 TUN 模式但打开🚀时,终端也会走代理,因为终端命令直接查询系统的route table,而🚀将其虚拟网卡设置为默认的系统路由。
  • 当流量已经被显式代理(如 http_proxy 设置为 127.0.0.1:1082)并送到了 Shadowrocket 的本地端口,它就完全绕过了系统路由 / TUN / TUN Excluded Routes 的判断逻辑,只会在 Shadowrocket 的"代理引擎"内部继续判断是否命中 Skip Proxy。

✅ The Solution

通过上述分析,可以定位问题为,浏览器发出的TS地址请求被🚀代理接口接管,无法到达系统路由表,导致无法访问TS网络。而命令行请求的TS地址直接查询系统路由表,可以正常访问TS网络。

所以,在 🚀的 Skip Proxy 中添加 TS 网段 100.64.0.0/10 即可, optional 可加上 *.ts.net

整理了一下浏览器和命令行访问TS网络的流程如下:

    flowchart TD
  %% 命令行 curl / ping 路径
  B[⬇️<br/>💻 curl(HTTP协议)<br/> 💻 ping(ICMP协议)] --> B1[查询系统 routes]

  B1 -->|match ts route| B2[TS utunX网卡]
  B2 --> B3[🌐✅成功访问TS<br/>💻✅成功访问TS]

  B1 -->|dont match any route| A14[系统default route]
  
  %% 浏览器路径
  A[⬇️<br/>🌐 浏览器访问<br/>(HTTP协议)] -->|✅设置了系统代理<br/>http_proxy + 🚀 on| A2[📤 请求发往🚀代理端口<br/>127.0.0.1:1082]
  A2 --> A3{命中🚀**Skip Proxy**?}
  A3 -->|否<br/>| A10[🚀代理]
  A10 --> A19[🌐✅成功访问Google<br/>🌐❌无法访问TS,🚀模拟返回503]
  A3 -->|是<br/>| A4[跳过🚀代理接口<br/>查询🚀TUN routes]
  A4 --> A5{命中🚀TUN<br/>**Excluded Routes**?}
  A5 -->|是| A6[跳过🚀TUN接口]
  A5 -->|否| A11[🚀TUN接口]

  A11 --> A19
  A6 --> B1

  A14 --> A15{🚀 on?}
  A15 -->|是| A16[🚀utun4网卡,🚀代理]
  A16 --> A20[💻✅成功访问Google]
  A15 -->|否| A17[无🚀代理,直连]
  A17 --> A21[💻❌无法访问Google]
  

References