标签: network

  • 手动升级一下 OpenWRT

    This entry is part 9 of 9 in the series homelab 历险记

    家里的软路由还在运行着几年前的 23.05,已经有些太旧了,趁着周末更新一下。

    由于我的 OpenWRT 使用了一些第三方 package repository,所以我没法使用最傻瓜式的 attendedsysupgrade 。Gemini 推荐我用 imagebuilder 自定义一个新版镜像,首先需要去官方的镜像下载站下载新版本的 imagebuilder 包。

    接着,解压得到的压缩包,并编辑其中的 repositories.conf 文件:

    ## Remote package repositories
    src/gz openwrt_core https://mirrors.tuna.tsinghua.edu.cn/openwrt/releases/24.10.5/targets/x86/64/packages
    src/gz openwrt_base https://mirrors.tuna.tsinghua.edu.cn/openwrt/releases/24.10.5/packages/x86_64/base
    src/gz openwrt_kmods https://mirrors.tuna.tsinghua.edu.cn/openwrt/releases/24.10.5/targets/x86/64/kmods/6.6.119-1-484466e2719a743506c36b4bb2103582
    src/gz openwrt_luci https://mirrors.tuna.tsinghua.edu.cn/openwrt/releases/24.10.5/packages/x86_64/luci
    src/gz openwrt_packages https://mirrors.tuna.tsinghua.edu.cn/openwrt/releases/24.10.5/packages/x86_64/packages
    src/gz openwrt_routing https://mirrors.tuna.tsinghua.edu.cn/openwrt/releases/24.10.5/packages/x86_64/routing
    src/gz openwrt_telephony https://mirrors.tuna.tsinghua.edu.cn/openwrt/releases/24.10.5/packages/x86_64/telephony
    
    # passwall2
    src/gz passwall_luci https://master.dl.sourceforge.net/project/openwrt-passwall-build/releases/packages-24.10/x86_64/passwall_luci
    src/gz passwall_packages https://master.dl.sourceforge.net/project/openwrt-passwall-build/releases/packages-24.10/x86_64/passwall_packages
    src/gz passwall2 https://master.dl.sourceforge.net/project/openwrt-passwall-build/releases/packages-24.10/x86_64/passwall2
    
    ## This is the local package repository, do not remove!
    src imagebuilder file:packages
    
    #option check_signature

    我的修改主要是 1)替换软件源为清华大学镜像;2)添加 passwall2 相关的软件源;3)禁用了 signature 校验(不知为何,在构建过程中, passwall2 的软件源签名会出错)。

    接着,还需要导出当前的 OpenWRT 中手动安装的软件列表,将其预置到自定义镜像中:

     echo $(opkg list-installed | sed -e "s/\s.*$//") > packages.txt

    将 packages.txt 也保存到 imagebuilder 的目录下之后,运行下面的代码就可以构建镜像了:

    cd ./path/to/openwrt-imagebuilder/
    packages=$(<packages.txt)
    make image PROFILE=generic PACKAGES="$packages" ROOTFS_PARTSIZE=1024

    稍等片刻,你就可以在 openwrt-imagebuilder/bin/targets/x86/64/openwrt-24.10.5-x86-64-generic-ext4-combined-efi.img.gz 下找的构建出来新镜像。

    更新 OpenWRT

    登录 OpenWRT 管理面板后,进入如下页面,并点击「更新固件」。

    固件上传后,根据弹出框提示就可以开始更新固件的流程了。

    Fediverse Reactions
  • 如何安全地面向公网提供本地 NAS 上的 Web 服务

    This entry is part 8 of 9 in the series homelab 历险记

    最近飞牛 NAS 爆出了一个 0Day 漏洞,很多开启了公网访问(包括但不限于 DDNS、公网 IP、FN Connect)的飞牛用户都中招了,不得不格盘重装系统。

    由于我一开始就不太信任飞牛团队以及我自己,所以除了 FN Connect 之外,飞牛的漏洞被披露后,我马上升级了最新版本,并关闭了 FN Connect,我没有提供任何直接的公网访问飞牛控制面板的方式 —— 想要从家庭局域网以外连接回去,必须使用我的 Singbox 服务。详情见2024 家庭网络设备大升级 网络篇

    不过,我确实有一些其他的网络服务出于一些原因需要暴露在公网。这些服务中,有一些是我手搓的,天生免疫大部分的针对 Web 应用的网络攻击,另外一些则是有一定用户基础的开源项目的自托管实例。这类服务就像飞牛一样,容易成为黑客广撒网的攻击目标。

    这些使用 Cloudflare Tunnel 暴露在公网的服务都有 Cloudflare 提供最基础的 Web 安全防护功能,但是防患于未然,我决定给这些比较脆弱的服务再多加上一层保护,这里我选择的是个人免费使用的雷池 WAF (Web Application Firewall)。

    设置好雷池后的示意图

    安装雷池

    为了充分享受 DIY 的好处,我建议手动安装雷池。为了让雷池适配我的家庭内网配置,我修改后的 Docker Compose 文件如下:

    networks:
      apps: #我手动创建的 Docker 网络,所有对外服务都需要加入这个网络
        external: true
      safeline-ce:
        name: safeline-ce
        driver: bridge
        ipam:
          driver: default
          config:
            - gateway: ${SUBNET_PREFIX:?SUBNET_PREFIX required}.1
              subnet: ${SUBNET_PREFIX}.0/24
        driver_opts:
          com.docker.network.bridge.name: safeline-ce
    
    services:
      postgres:
        container_name: safeline-pg
        restart: always
        image: ${IMAGE_PREFIX}/safeline-postgres${ARCH_SUFFIX}:15.2
        volumes:
          - ${SAFELINE_DIR}/resources/postgres/data:/var/lib/postgresql/data
          - /etc/localtime:/etc/localtime:ro
        environment:
          - POSTGRES_USER=safeline-ce
          - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?postgres password required}
        networks:
          safeline-ce:
            ipv4_address: ${SUBNET_PREFIX}.2
        command: [postgres, -c, max_connections=600]
        healthcheck:
          test: pg_isready -U safeline-ce -d safeline-ce
      mgt:
        container_name: safeline-mgt
        restart: always
        image: ${IMAGE_PREFIX}/safeline-mgt${REGION}${ARCH_SUFFIX}:${IMAGE_TAG:?image tag required}
        volumes:
          - /etc/localtime:/etc/localtime:ro
          - ${SAFELINE_DIR}/resources/mgt:/app/data
          - ${SAFELINE_DIR}/logs/nginx:/app/log/nginx:z
          - ${SAFELINE_DIR}/resources/sock:/app/sock
          - /var/run:/app/run
        ports:
          - ${MGT_PORT:-9443}:1443
        healthcheck:
          test: curl -k -f https://localhost:1443/api/open/health
        environment:
          - MGT_PG=postgres://safeline-ce:${POSTGRES_PASSWORD}@safeline-pg/safeline-ce?sslmode=disable
          - MGT_PROXY=${MGT_PROXY}
        depends_on:
          - postgres
          - fvm
        logging:
          driver: "json-file"
          options:
            max-size: "100m"
            max-file: "5"
        networks:
          safeline-ce:
            ipv4_address: ${SUBNET_PREFIX}.4
      detect:
        container_name: safeline-detector
        restart: always
        image: ${IMAGE_PREFIX}/safeline-detector${REGION}${ARCH_SUFFIX}:${IMAGE_TAG}
        volumes:
          - ${SAFELINE_DIR}/resources/detector:/resources/detector
          - ${SAFELINE_DIR}/logs/detector:/logs/detector
          - /etc/localtime:/etc/localtime:ro
        environment:
          - LOG_DIR=/logs/detector
        networks:
          safeline-ce:
            ipv4_address: ${SUBNET_PREFIX}.5
      tengine:
        container_name: safeline-tengine
        restart: always
        image: ${IMAGE_PREFIX}/safeline-tengine${REGION}${ARCH_SUFFIX}:${IMAGE_TAG}
        volumes:
          - /etc/localtime:/etc/localtime:ro
          # 注意!!!我删除了这里的 /etc/resolv.conf 挂载
          # 因为 tengine 加入了 apps 网络,必须使用 Docker 内置的 DNS
          # - /etc/resolv.conf:/etc/resolv.conf:ro
          - ${SAFELINE_DIR}/resources/nginx:/etc/nginx
          - ${SAFELINE_DIR}/resources/detector:/resources/detector
          - ${SAFELINE_DIR}/resources/chaos:/resources/chaos
          - ${SAFELINE_DIR}/logs/nginx:/var/log/nginx:z
          - ${SAFELINE_DIR}/resources/cache:/usr/local/nginx/cache
          - ${SAFELINE_DIR}/resources/sock:/app/sock
        environment:
          - TCD_MGT_API=https://${SUBNET_PREFIX}.4:1443/api/open/publish/server
          - TCD_SNSERVER=${SUBNET_PREFIX}.5:8000
          # deprecated
          - SNSERVER_ADDR=${SUBNET_PREFIX}.5:8000
          - CHAOS_ADDR=${SUBNET_PREFIX}.10
        ulimits:
          nofile: 131072
        # 这里使用 apps 网络替代主机模式网络,避免占用主机 80/443 端口
        #network_mode: host
        networks:
          - apps
      luigi:
        container_name: safeline-luigi
        restart: always
        image: ${IMAGE_PREFIX}/safeline-luigi${REGION}${ARCH_SUFFIX}:${IMAGE_TAG}
        environment:
          - MGT_IP=${SUBNET_PREFIX}.4
          - LUIGI_PG=postgres://safeline-ce:${POSTGRES_PASSWORD}@safeline-pg/safeline-ce?sslmode=disable
        volumes:
          - /etc/localtime:/etc/localtime:ro
          - ${SAFELINE_DIR}/resources/luigi:/app/data
        logging:
          driver: "json-file"
          options:
            max-size: "100m"
            max-file: "5"
        depends_on:
          - detect
          - mgt
        networks:
          safeline-ce:
            ipv4_address: ${SUBNET_PREFIX}.7
      fvm:
        container_name: safeline-fvm
        restart: always
        image: ${IMAGE_PREFIX}/safeline-fvm${REGION}${ARCH_SUFFIX}:${IMAGE_TAG}
        volumes:
          - /etc/localtime:/etc/localtime:ro
        logging:
          driver: "json-file"
          options:
            max-size: "100m"
            max-file: "5"
        networks:
          safeline-ce:
            ipv4_address: ${SUBNET_PREFIX}.8
      chaos:
        container_name: safeline-chaos
        restart: always
        image: ${IMAGE_PREFIX}/safeline-chaos${REGION}${ARCH_SUFFIX}:${IMAGE_TAG}
        logging:
          driver: "json-file"
          options:
            max-size: "100m"
            max-file: "10"
        environment:
          - DB_ADDR=postgres://safeline-ce:${POSTGRES_PASSWORD}@safeline-pg/safeline-ce?sslmode=disable
        volumes:
          - ${SAFELINE_DIR}/resources/sock:/app/sock
          - ${SAFELINE_DIR}/resources/chaos:/app/chaos
        networks:
          safeline-ce:
            ipv4_address: ${SUBNET_PREFIX}.10
    

    使用任何你喜欢的方式启动这个 docker-compose 文件,部署雷池的工作就完成了大半。

    对接 Cloudflare Tunnel

    Cloudflare Tunnel 是一个免费的内网穿透工具,可以将来自公网的请求通过 Cloudflare 网络导向没有公网地址的 Web 服务。要使用 Cloudflare Tunnel 可以参考这里的文档

    我这里简单介绍一下在内网部署 Cloudflared 的 docker-compose 文件:

    services:
      cloudflared:
        image: cloudflare/cloudflared:latest
        container_name: cloudflared
        restart: always
        pull_policy: always
        command: tunnel --no-autoupdate run --token $TUNNEL_TOKEN
        networks:
          - apps # 注意!!!需要让 cloudflared 也加入 apps 网络,否则无法将流量导向雷池
        environment:
          - TUNNEL_TOKEN=${TUNNEL_TOKEN}
        volumes:
          - ./cloudflared-data:/home/nonroot/.cloudflared
    
    networks:
      apps:
        external: true

    完成 Cloudflare Tunnel 的部署后,还需要更新一下雷池的配置,让雷池可以读取 Cloudflare 传递过来的客户端 IP。

    首先打开雷池「防护应用」页面:

    点击右上角的「高级配置」,修改源 IP 获取方式为「从 HTTP Header 中获取」,并将 Header 设置为 「CF-Connection-IP」。

    添加公网应用

    假设我们已经通过飞牛的 Docker 管理工具部署了一个 openlist,并且该服务也加入了 apps 网络。我们可以先打开雷池的「防护应用」页面,在右上角点击「添加应用」:

    这里有几点需要注意,域名须填写用于在公网访问的域名,但上游服务器要使用 apps 内网中的 docker service name。

    这里不需要启用 HTTPS,因为 cloudflared 跟雷池的网关都运行在受控制的环境下,启用 HTTPS 没有太多好处,反而配置跟运维会变得复杂。

    添加雷池的应用后,就需要去 Cloudflare 的面板添加一条从公网导向局域网的路由:

    进入 Tunnel 的编辑页面,切换到 「Published application routes」页面,点击「Add a published appIication route」。

    填写子域名后将服务类型设置为 HTTP,并设置 URL 为 tengine:80 (这就是雷池网关在 apps 网络中的地址)。这样,运行在内网的应用就可以比较安全的面向公网提供服务了。

    最大的安全隐患

    说实话,允许公网访问就是最大的安全隐患,要消除这个隐患,最简单有效的做法就是使用 VPN 并关闭任何通过公网直接访问的途径。

    如果你跟我一样都有不得不对公网提供服务的需求,那么配置一些简单的防火墙安全规则,就可以有效防御来自公网的攻击。

    Fediverse Reactions
  • 迁移博客到 VPS

    在上篇文章优化了博客网站的性能过后,我对页面加载速度仍感到不太满意,Cloudflare 显示大部分页面仍需要等待接近 2 秒才能完成响应。一想到我之前在 cPanel 上执行了一些比较重的操作,网页的加载速度就会下降,所以可能之前的 Host Provider 并没有给我分配多少资源来折腾,这大概率就是网页访问速度慢的重要原因之一。

    为了验证这个观点,我尝试在 Homelab 中的 N100 主机上部署了一个 WordPress,并将博客网站复制了一份到这台服务器上。我发现在缺少 Cloudflare 缓存的情况下,就算是通过 Cloudflare Connector 从公网访问家庭内网中的网站都比我线上的博客网站要快 😂。

    确认了是 Host Provider 的性能问题后,我决定将博客网站迁移到我的一台 VPS 上,那么这篇文章的重点终于开始了。

    我的 Word Press 实例一开始就是通过 Host Provider 建立的,之前在不同的 Provider 之间迁移时,主要也是通过管理面板上提供的一键迁移功能完成操作。但我没想到的是,这些一键迁移功能所涉及的插件都是付费的,之前之所以能顺畅的使用,大概率是 Host Provider 已经为这些功能付过了钱。

    虽然插件市场中也有免费的备份恢复插件,但要么是备份出来之后不让恢复,要么是恢复备份会出错, 更离谱的是有些插件备份出来的数据竟然是加密的。

    不过好在管理面板提供了一个简单的 Full Backup 功能,可以将 WordPress 目录连同整个数据库 Dump 一起打包。

    在我的 VPS 上,出于方便我一直没安装任何面板,唯一的管理工具是 CapRover ,你将其简单的理解为自部署版 heroku。他自带的 WordPress 的部署模板,只要简单替换一下变量,就可以快速启动一个 WordPress 容器:

    在这里,需要注意的 WordPress 的 Docker 镜像默认不包含 WP-CLI,需要使用 6.9.0-cli 标签才能部署带 WP-CLI 的镜像。

    由于 CapRover 的限制,部署好的 WordPress 只能通过 CapRover 的 Nginx 网关访问,而 Nginx 网关已经处理了 TLS,所以转发到 WordPress 的流量只有 HTTP。

    如果你从零使用 Docker 创建 WordPress 实例,上述的情况不会影响你,但如果使用从其他地方复制过来的 WordPress 实例,就会遇到无限重定向的问题。具体表现为访问 /wp-admin 时,会响应当前重定向到当前 URL 。这时,就需要在 wp-config.php 的最顶部,添加下面的代码:

    if (strpos($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') !== false)
       $_SERVER['HTTPS']='on';
    else
       $_SERVER['HTTPS']='off';

    后记

    上面就是迁移博客过程中比较大的坑点了,其实就算花 $9.9 买一个一键迁移插件可能也无法避开。但如果我在原来的 Provider 上加钱升级配置,可能也就不需要这次的迁移了。根据基本的经济学原理,我今天下午工作的价值就是下一档配置的 3 年订阅费,大概 $300,又省到钱了~

  • 优化博客网站的性能

    昨天收到了 Google Search Console 的提醒,我的博客网站有部分网页响应速度太慢了,影响用户体验。性能差我很早就有体会,不过当时将其归咎于我本地的网络环境没配置好。现在连 Google 都抱怨我的博客网站慢,那就不太可能是我的网络环境问题了。

    当我们从地址栏输入网址并按下回车后,哪些地方最容易出现性能问题?电脑网卡、家庭路由器、社区网关、服务器网卡还是应用程序代码?

    由于 Google 的报告,我快速排除了服务器网卡之前的选项,范围缩小到了服务器网卡问题跟应用程序代码慢之间。我在网上找到了一个 WordPress 插件 Code Profiler ,可以用来分析处理请求过程中的插件执行耗时。

    我随机抽取了一篇很少被访问的博文,加上了禁用缓存的查询参数后,插件性能分析结果如下:

    可以看到整个请求完成时间接近 1s,其中的 Jetpack 耗时接近 300ms。喷气背包可能在反向喷气。

    根据这篇文章的介绍,我关闭了所有的 Jetpack 模块,但对响应速度没有太多影响。只有在彻底关闭 Jetpack 后,请求响应时间降到了 500ms 以内。这对于只有 1C1G 的共享空间来说,已经可以接受了。除此之外,内存开销、文件读写数量也都降低了15%。

    当然,关闭 Jetpack 有一些代价,对我影响最大的是评论区不能支持 Markdown 了,发布新博文不能同步创建一个长毛象嘟嘟了。

    总而言之,对我在使用的这台非常弱鸡的服务器来说,Jetpack 有些过于沉重了。

  • N100 小主机遭遇 NVMe 硬盘故障:一次系统的诊断与反思

    This entry is part 6 of 9 in the series homelab 历险记

    我的 N100 小主机最近陷入了无响应的困境,一次系统级的卡顿,让我不得不深入诊断底层硬件问题。最终,故障源头被锁定在一块出现问题的 NVMe 硬盘上。

    故障的发生与初步判断

    故障最初表现为:系统在日常运行中,当需要读取某些特定数据时,NVMe 硬盘会陷入无响应状态。这直接导致相关进程卡滞在 D 状态(不可中断睡眠状态)。当这类进程累积过多时,整个操作系统陷入僵局,无法响应任何其他请求。

    首次尝试解决,我选择了重启。然而,系统通常能坚持不到十分钟,便会在 CPU 占用率不高的正常负载情况下再次卡死。这表明问题并非偶发,而是存在深层原因。

    精准定位故障源头

    为了区分软件与硬件故障,我启动了 SystemRescue Live CD。在 Live CD 环境下,我开始使用 smartctl 工具检查硬盘的健康状况。令人惊讶的是,仅仅执行 smartctl -x /path/to/disk 这样的详细健康状况检查命令,就能稳定复现系统卡死的故障。

    这一现象直接排除了操作系统层面的复杂问题,将故障的矛头清晰地指向了 NVMe 硬盘本身。

    为了进一步验证,我将这块故障硬盘从 N100 小主机上取下,安装到另一台笔记本电脑上进行测试。结果,同样的卡死问题依然能够重现。这最终确认了硬盘是故障的根本原因,而非 N100 主机的 PCIe 接口或其他主机侧问题。

    数据与保修:选择与取舍

    这块 NVMe 硬盘尚未过保,这意味着我可以直接申请售后换新。然而,更换新硬盘的代价是硬盘上的所有数据。这块硬盘主要保存着我部署在 Docker 上的应用数据以及一些从网络下载的影片。

    幸运的是,Docker 应用的关键数据每天都通过 restic 进行备份。因此,更换新硬盘对我来说,影响微乎其微,核心应用数据和配置都得以安全保存。至于影片文件,虽然会丢失,但并非不可恢复。

    突发状况的应急预案

    这次故障也促使我思考关键服务的紧急应对措施。我的 N100 宿主机上运行着一个软路由虚拟机,这意味着一旦宿主机故障,全屋的网络将随之瘫痪。

    发生故障时,我的临时解决方案是:使用一根网线连接器,将 N100 主机上原本连接 LAN 与 WAN 的网线直连起来,并让家中的光猫直接充当网关。这样可以在最短时间内恢复主要的网络连接,保障家庭网络的正常运行。这为后续的故障排查与修复争取了宝贵的时间。

    重要的经验教训

    尽管 Docker 应用数据有定期备份,但这次经历也暴露了一个疏漏:我却忽略了软路由虚拟机磁盘映象(VM Disk Image)的备份。软路由的配置和运行状态都包含在这个映象文件中,一旦丢失,重建起来会相当耗时。

    好在这次运气眷顾,通过 Live CD 成功抢救回了这份重要的虚拟机映象文件,避免了额外的麻烦。

  • How to simulate hard disconnection for websocket

    虽然 Chrome/Firefox 在开发者工具中提供了离线模式,但是这个功能无法中断已经跟服务端建立起来的 WebSocket 连接。

    如果你跟我一样使用 Linux 系统,那么就可以借助 iptables 命令来模拟连接中断的情况:

    # 中断所有访问 localhost:3001 的连接
    sudo iptables -I INPUT 1 -p tcp -i lo --dport 3001 -j DROP
    
    # 恢复连接
    sudo iptables -D INPUT -p tcp -i lo --dport 3001 -j DROP

    如果你发现上面的命令不生效,那么可能是因为你的浏览器在通过 IPv6 地址访问本地端口,你需要将 iptables 替换成 ip6tables

    如果你是基于 iptables 实现的 ufw 用户,你会发现 ufw 中建立的本地端口规则完全不生效,这是因为 ufw 不会处理任何从本机访问本机的连接,我们只能手动调用更底层的 iptables

  • OpenWRT 上的 zerotier 突然无法启动了

    This entry is part 5 of 9 in the series homelab 历险记

    在我之前的文章中,介绍了我从公网远程访问家庭内网的方式,但在上个周末,这套配置出现了故障。故障的表现是 OpenWRT 上的 zerotier 客户端无法启动:

    zerotier-one[21492]: terminate called after throwing an instance of 'std::bad_cast'

    通过搜索得知这个故障通常伴随着硬盘问题,比如磁盘空间不足。通过 df -h 指令可以看到 /tmp 分区满了,而在 OpenWRT 上 /var 实际上会被链接到 /tmp 分区,这就导致 zerotier 客户端在启动时无法将配置写入 /var/lib/zerotier-one/networks.d 文件夹中。

    使用 du 命令,可以看到是 singbox 的日志文件充满了 /tmp 分区,强行删除日志文件后,虽然 df 命令可以看到空间被释放了,但实际上尝试写入 /var 仍然会报空间不足的错误。

    在 Gemini 的指点下,得知这很可能是因为仍然存在进程在使用这个文件,导致磁盘空间并没有被释放。果然,重启 singbox 进程后, zerotier 客户端终于能正常启动了。

    为了避免以后再有类似的情况出现,我干脆关闭了 singbox 的日志,等需要 debug 的时候再开启。

  • 给 QNAP 文件分享外链套个壳

    This entry is part 4 of 9 in the series homelab 历险记

    QNAP 提供的在线服务 MyQNAPCloud 的下载速度有时真的非常令人捉急。为了方便他人下载我分享的文件,得提供一个允许公网访问 NAS 共享链接的方式。

    基础设施改动

    考虑到我现在 Homelab 的基础设施中已经有了 cloudflared,所以可以用它设置一个 tunnel,但是为了避免将 NAS 其他功能暴露到公网,还需要再用 Nginx 设置一个代理,拒绝所有访问非共享链接页面的请求:

    location ^~ /share.cgi {
      proxy_pass https://qnap.lan:5001;
    }
    
    location ~* \.(js|svg|gif|png)$ {
      proxy_pass https://qnap.lan:5001;
    }
    
    
    location ^~ /shareLink/ {
      proxy_pass https://qnap.lan:5001;
    }
    
    location / {
      return 302 "https://zeeko.dev";
    }

    接下来,设置 Cloudflared 的 tunnel,将 Local Service 的地址改成这台 nginx 服务器。

    上面之所以还配置了 Host Header 是因为我的 nginx 上配置的域名是给局域网专用的,跟公网域名不同,为了让 Nginx 能够正常识别请求,就需要让 Cloudflared 设置转发 Host Header。

    UI 体验优化

    设置好网络功能后,还需要优化一下 QNAP 管理页面的 UI,方便我复制公网链接。这里我选择使用油猴脚本来实现我的目的。

    // ==UserScript==
    // @name         My QNAP
    // @namespace    https://qnap.lan/
    // @version      2025-05-31
    // @description  try to take over the QNAP!
    // @author       You
    // @match        https://qnap.lan/*
    // @icon         https://www.google.com/s2/favicons?sz=64&domain=tampermonkey.net
    // @grant        none
    // ==/UserScript==
    
    (function() {
        'use strict';
    
        // Configuration for the observer
        const observerConfig = {
            childList: true,
            subtree: true,
            attributes: false,
            characterData: false
        };
    
        // Create an observer instance
        const observer = new MutationObserver(mutations => {
            requestIdleCallback(() => {
                // DEBUG: Get all label elements
                const allLabels = Array.from(document.querySelectorAll('label.x-form-item-label'));
                // DEBUG: Find Local IP label
                const localIPLabel = allLabels
                .find(it => it.innerText === 'Local IP');
                console.debug('[DEBUG] localIPLabel:', localIPLabel);
                if (localIPLabel == null) {
                    console.warn('[DEBUG] Local IP label not found');
                    return;
                }
                const localIP = localIPLabel.nextElementSibling.innerText.trim();
                console.debug('[DEBUG] localIP:', localIP);
    
                if(localIP == null || localIP === '') {
                    console.warn('[DEBUG] localIP is null or empty');
                    return;
                }
    
                const query = new URL(localIP).search;
                console.debug('[DEBUG] query:', query);
    
                // DEBUG: Find SmartShare label
                const smartShareLabel = allLabels
                .find(it => it.innerText === 'SmartShare');
                if (smartShareLabel == null) {
                    console.warn('[DEBUG] SmartShare label not found');
                    return;
                }
                const copyButton = smartShareLabel.nextElementSibling.querySelector('button');
                if (copyButton == null) {
                    console.warn('[DEBUG] Copy button not found');
                    return;
                }
    
                copyButton.addEventListener('click', () => {
                    const shareUrl = 'https://smartshare.zeeko.dev/share.cgi' + query;
                    navigator.clipboard.writeText(shareUrl);
                });
    
            });
        });
    
        // Start observing the document body
        observer.observe(document.body, observerConfig);
    
        // Cleanup observer when page unloads
        window.addEventListener('unload', () => {
            observer.disconnect();
        });
    })();

    启用上面的脚本后,就可以在 UI 上直接复制文件共享的公网链接:

    最终效果

    相比通过 QNAP 的云下载,速度要快了 10 倍。

  • Zerotier: No route to host

    This entry is part 3 of 9 in the series homelab 历险记

    一觉醒来,突然发现家里的 OpenWRT 软路由无法通过 Zerotier 网络访问了,虽然面板上显示设备在线,但不管是 Ping 还是 curl ,都会报错:No route to host。

    09:54:21.055775 [0-0] * [HTTPS-CONNECT] adjust_pollset -> 1 socks
    09:54:22.048545 [0-0] * [HTTPS-CONNECT] connect, check h21
    09:54:22.049187 [0-0] * connect to 192.168.100.22 port 443 from 10.1.1.111 port 59496 failed: No route to host

    首先,我们需要判断这个问题是否能在 Zerotier 网络中其他节点复现,通过登录不同节点执行 ping 跟 curl,发现只有 OpenWRT 在 Zerotier 网络中不可达。

    接下来登录 OpenWRT 上查看 Zerotier 客户端的状态:

    $ zerotier-cli info -j
    {
     "address": "....",
     "clock": 1748052290108,
     "config": {
      "settings": {
       "allowTcpFallbackRelay": true,
       "forceTcpRelay": false,
       "homeDir": "/var/lib/zerotier-one",
       "listeningOn": [
        "192.168.100.1/9993",
        "192.168.1.2/9993"
       ],
       "portMappingEnabled": true,
       "primaryPort": 9993,
       "secondaryPort": 44537,
       "softwareUpdate": "disable",
       "softwareUpdateChannel": "release",
       "surfaceAddresses": [
        "家宽公网 IP"
       ],
       "tertiaryPort": 44486
      }
     },
     "online": true,
     "planetWorldId": 149604618,
     "planetWorldTimestamp": 1738848951118,
     "publicIdentity": "....",
     "tcpFallbackActive": false,
     "version": "1.14.1",
     "versionBuild": 0,
     "versionMajor": 1,
     "versionMinor": 14,
     "versionRev": 1
    }
    
    $ zerotier-cli peers
    200 peers
    <ztaddr>   <ver>  <role> <lat> <link>   <lastTX> <lastRX> <path>
    778cde7190 -      PLANET   225 DIRECT   44578    134480   103.195.103.66/9993
    cafe04eba9 -      PLANET   318 DIRECT   44578    134387   84.17.53.155/9993
    cafe80ed74 -      PLANET   165 DIRECT   4519     129532   185.152.67.145/9993
    cafefd6717 -      PLANET   263 DIRECT   44578    129434   79.127.159.187/9993

    可以看到 OpenWRT 上的客户端并没有显示任何异常信息,这也对应了控制面板上看到的设备在线状态。但值得注意的是,在 surfaceAddresses 字段上显示的是我的家庭宽带公网出口地址,这也对应了控制面板上显示的该设备的物理地址。考虑到 Zerotier 可能已经被中国联通给 Ban 了,所以接下来可以尝试让 Zerotier 客户端通过代理连接 Planet。

    这时,有意思的事情出现了,尽管我在代理软件中正确地设置了 Zerotier 的协议转发规则(转发目标端口为 9993 的 TCP/UDP 流量),但我在代理软件的日志中却无法查看到 Zerotier 相关的流量信息。

    我的规则设置有误还是代理软件在这个场景下存在 Bug ?这可以通过一个实验来验证,使用 ncat -vuz 84.17.53.155 9993 就可以向 Zerotier 的 Planet 发送一个用来测试连接性的 UDP 包。

    Ncat: Version 7.93 ( https://nmap.org/ncat )
    Ncat: Connected to 84.17.53.155:9993.
    Ncat: UDP packet sent successfully
    Ncat: 1 bytes sent, 0 bytes received in 2.00 seconds.

    可以看到这个 UDP 包被成功发送了,而且在代理软件的日志中也能找到相关的转发记录。这说明我的代理软件规则配置是正确的,而且代理软件也没有任何问题。那为什么在代理软件的日志中看不到 Zerotier 的流量呢,难道 Zerotier 客户端压根没有去连接 Planet ?这就需要使用 Wireshark 来分析 OpenWRT 的流量了。

    # on my laptop
    $ ssh openwrt 'tcpdump  -s 0 -U -n -w - -i eth0' | sudo wireshark -k -i -<br>

    通过分析 OpenWRT 各个网络设备上的流量,可以发现 Zerotier 的 UDP 包确实被发送了,但它是通过 OpenWRT 的 WAN 地址发送的,而我的代理软件只会通过 nft 拦截来自 localhost 跟 LAN 的流量,这就解释了为什么代理软件无法转发 Zerotier 的流量。

    确实,在网络编程中,当机器被绑定了多个 IP 地址时,我们可以指定某个特定的 IP 作为源 IP,对于 VPN 软件 Zerotier 来说,这样做更是基操。所以,在 Zerotier 的配置文件中肯定存在某个配置项,用来设置 Zerotier 流量的出口网络。

    通过翻阅它的文档,可以看到有两个选项会影响 Zerotier 流量的发送地址:

    {
      "settings": {
        "interfacePrefixBlacklist": [ "XXX",... ], /* Array of interface name prefixes (e.g. eth for eth#) to blacklist for ZT traffic */
        "bind": [ "ip",... ], /* If present and non-null, bind to these IPs instead of to each interface (wildcard IP allowed) */
      }
    }

    在我的测试下,需要指定 bind 为 LAN 地址,才能真正解决这个问题。这样设置后,Zerotier 就会通过代理服务跟 Planet 沟通,在管理面板上,OpenWRT 的物理地址也会被展示成代理服务的 IP。

    实测下来,从外网访问 OpenWRT 的速度还是能达到家庭宽带的上限,网络延迟也还不错,问题终于解决了~

  • 解决 Cloudflare Tunnel 无法通过 Passwall2 连接的问题

    This entry is part 3 of 3 in the series 网上冲浪指南

    最近发现家里 NAS 上部署的 cloudflared 很容易掉线,切换节点也不好使。

    找了一圈互联网资料后,最后根据这篇文章的描述,成功修复了 passwall2 的配置。

    简单来说, Cloudflare Tunnel 客户端(Cloudflared)会先解析 region1.v2.argotunnel.com/region2.v2.argotunnel.com 的地址,再用 quic.cftunnel.com/h2.cftunnel.com 作为 SNI 去请求。

    当开启 Passwall2 上的覆盖连接目标地址功能时,Xray 就会在转发流量时用 *.cftunnel.com 替换掉 Cloudflared 客户端获取的真实地址,当流量达到远程 Xray 时,由于 *.cftunnel.com 并没有 A/AAAA DNS 记录,就会导致流量转发失败,从而出现这样的错误:

    2025-05-09T14:07:05Z ERR Serve tunnel error error="TLS handshake with edge error: EOF" connIndex=0 event=0 ip=198.41.192.57

    要解决这个问题,需要禁用掉 Passwall2 上对 Cloudflare tunnel 相关域名的嗅探功能。

    或者,如果你能保证客户端 DNS 解析没有问题的话,可以干脆禁用掉「覆盖连接目标地址」功能,这也是 Xray 文档中推荐的做法:

    在能保证 被代理连接能得到正确的 DNS 解析 时,使用 routeOnly 且开启 destOverride 的同时,将路由匹配策略 domainStrategy 设置为 AsIs 即可实现全程无 DNS 解析进行域名及 IP 分流。此时遇到 IP 规则匹配时使用的 IP 为域名原始 IP。

    https://xtls.github.io/config/inbound.html#sniffingobject