标签: NAS

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

    This entry is part 5 of 5 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 5 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 倍。

  • Obsidian 数据同步方案:从 Git 到 Syncthing 的平滑切换

    Obsidian 作为一款本地优先的笔记软件,数据安全始终是用户关注的重点。我之前选择 Git 来进行版本控制,但随着 Obsidian 插件的不断增加,有些插件会生成大量二进制文件,这些文件如果直接存储在 Git 仓库中,会快速消耗 Git LFS 的免费存储额度。

    我最初考虑使用 Remotely Save 插件的 WebDAV 功能将数据备份到 NAS,但在 QNAP 上频繁遇到 403 错误,最终决定采用更稳定的 Syncthing 方案。

    Syncthing 在 QNAP 上的部署要点

    在 QNAP 上安装 Syncthing 需要通过第三方软件源 MyQNAP.org,该源提供了两个 Syncthing 安装包:

    • Syncthing:以普通用户权限运行
    • Syncthing Run As Root:以 root 用户权限运行

    建议选择 Syncthing,原因如下:

    1. 权限管控更安全:避免 root 权限带来的安全隐患
    2. 多用户兼容性更好:非管理员用户也能正常访问 Syncthing 同步的文件

    :安装后系统会自动创建 syncthing 用户,默认属于 everyone 用户组

    配置优化建议

    1. 文件夹权限设置: 同步文件夹的权限配置需要特别注意,建议设置为 <user>:everyone 模式,确保 Syncthing 有完整的读写权限。
    2. Ignore Permissions 选项:在 Syncthing 中启用该选项,因为普通用户权限的 Syncthing 无法修改其他 NAS 用户的文件权限。
    3. Linux 桌面端推荐:建议使用 Syncthing GTK,它提供了更友好的图形界面,并支持后台运行。
    4. QNAP 共享文件夹的位置:在 QNAP 中,用户创建的共享文件夹都位于 /share/ 目录下。

    Web UI 访问优化

    如果通过 Nginx Proxy Manager 反向代理 Syncthing 的 Web UI,请务必执行以下设置:

    • 取消 Cache Assets 选项
    • 原因:Syncthing 依赖 /meta.js 文件验证登录状态,缓存 Assets 会导致登录功能异常

    这种同步方案既解决了 Git LFS 的存储限制,又避免了 Remotely Save 的 403 问题,是目前我认为比较理想的 Obsidian 数据同步方案。建议在切换前做好数据备份,确保万无一失。

  • 2024 家庭网络设备大升级 网络篇

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

    网络设备硬件到齐之后,就需要重新配置网络了,在我的使用场景中,家庭网络除了基本的上网之外需要承担两个额外的功能:

    • 全局透明代理
    • 允许电脑、手机远程访问本地网络服务

    我选择的方案是使用 N100 小主机运行一个 OpenWRT 虚拟机,并将两个物理网卡分配给它,以实现更加高级的网络配置。

    怎么连网线

    因为家宽的下行带宽只有 300M,所以就用开发商自带的超五类网线连接光猫跟 N100 小主机的千兆 WAN 口。

    N100 小主机的 2.5G LAN 口跟一台 TP-Link 路由器连接,路由器设置有线 AP 模式,这样全屋所有的流量都由虚拟机软路由处理,用 openwrt-passwall2 实现全局透明代理功能。

    QNAP NAS 通过网线连接到 TP-Link 路由器,不过由于路由器只有千兆网口,所以 QNAP 的2.5G 网口完全是性能过剩的。

    N100 上再用一个有线网卡连接到 TP-Link 路由器,给宿主机联网使用,这个有线网卡配置了一个静态 IP,方便在软路由挂掉的时候访问管理面板排查故障。

    其他的设备都用无线网络连接,因为户型小,所以单个 TP-Link 路由器的 5GHz WiFi 完全可以覆盖主要的视频消费设备。至于厨房、阳台等犄角旮旯的物联网设备,2.4GHz WiFi 也能提供足够的覆盖度。

    怎么从任意的外部网络访问家里的局域网

    很多 homelab 玩家会选择公网 IP 搭配 DDNS 的方案,但对我来说,这种方案会显著提高局域网的安全风险 —— 我并不是一个 OpenWRT 的运维高手,也完全不会使用 iptables/nft ,如果有人通过公网 IP 侵入了局域网,我大概率很难及时地发现问题。

    我的方案是 zerotier + 拥有公网 IP 的 VPS + singbox。外网设备通过 singbox 客户端连接 VPS 上的 singbox 服务端,然后通过路由规则将发往局域网的流量通过 zerotier 转发到家庭网络的 OpenWRT 上,以此实现远程访问。

    首先,将局域网中的 OpenWRT 跟 VPS 都加入同一个 zerotier 网络。OpenWRT 加入 zerotier 比较复杂,建议参考 openwrt 的文档配置。

    在 OpenWRT 上成功加入 zerotier 网络后,就可以看到下面的接口以及防火墙配置:

    然后在 VPS 上添加一条路由规则:

    ip route add 192.168.100.0/24 via 10.2.2.233
    # 10.2.2.233 就是 OpenWRT 在 zerotier 网段中的 IP

    接着其他设备在外部网络下通过 singbox 连接到 VPS,并且添加下面的 singbox 路由规则:

    "route": {
      "rules": [
        {
          // 家庭局域网网段
          "ip_cidr": [
            "192.168.100.0/24"
          ],
          "outbound": "vps"
        },
        {
          // 局域网服务专属域名,在公网设置解析到家庭局域网的 Web 网关
          "domain_suffix": [
            ".n.zeeko.dev"
          ],
          "outbound": "vps"
        },
      ]
    }

    这样不仅可以减少家庭局域网在公网上暴露面,还方便各种设备(笔记本电脑、手机、平板)在各种网络条件下访问家里的局域网设备 —— 只要能连接上部署了 singbox 的 VPS,就可以访问家里的局域网。至于远程访问时的网络连接速度,实测在联通 5G 网络下满足远程播放 20Mbps 码率视频的场景比哔哩哔哩大会员的 4K 视频要强