作者: Zeeko

  • 提升机械革命无界 15XPro 暴风雪的充电速度

    This entry is part 2 of 4 in the series 机械革命无界 15XPro

    无界15XPro 的 99Wh 电池容量属实很大,但充电速度一言难尽,基本上要 4~5 小时左右才能从 10% 充到 100%。经过一番搜索后,总算找到了提升充电速度的方法。

    安装 Tuxedo Control Center 后,可以在 Settings 中找到 Battery Charging Options,只需要将 USB-C charging options 设置为 Priorize battery charing speed 就行。另外,如果长时间插电使用,还可以将 Charging profiles 设置成 Stationary use,这将会在硬件层面限制电池充电到 80% (但系统设置仍会显示充电到 100%)。

    如果你没安装 Tuxedo Control Center 也没关系,只要安装了驱动程序 (aur/mechrevo-drivers-dkms) ,就能通过下面两个 sysfs 接口实现同样的功能:

    cat /sys/bus/platform/drivers/tuxedo_keyboard/tuxedo_keyboard/charging_profile/charging_profile
    cat /sys/bus/platform/drivers/tuxedo_keyboard/tuxedo_keyboard/charging_priority/charging_prio
  • OpenWRT 上的 zerotier 突然无法启动了

    This entry is part 5 of 7 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 的时候再开启。

  • OBS 在 Linux 下启用 AMD GPU 加速

    OBS 在 Linux 平台下默认使用软件视频编码器,从而在录屏的时候造成 CPU 干活,GPU 摸鱼的场面。

    像我在使用的 AMD GPU,在正确安装驱动程序后,修改 OBS 设置中的 Output Mode 为 Advanced ,然后选择 FFmpeg VAAPI 作为视频编码器,这样就可以使用 GPU 编码视频了。

    可以看到, OBS 的 CPU 使用率大幅下降,并且 GPU 也在干活了。

  • claude code 使用心得

    一些常用的 /slash-commands

    /init

    让 claude code 到项目之前,最好先执行一下 /init,这样 claude code 就可以更加了解项目的功能以及编码习惯。不定期地在会话中执行 /init 也可以让 cc 及时了解项目的新规则,帮助它写出规范的代码。

    /compact

    用来压缩上下文,保留当前会话的记忆,但更省 token。

    /clear

    用来重置上下文,完全清楚当前会话的记忆,相当于重新开始会话。

    # new memory

    使用 # 开头就可以让 claude code 将你交代的事项记录在备忘录中,可以用来拓展 claude.md 中遗漏的项目约定或者你个人的习惯。

    向 claude code 布置任务的语言技巧

    我最常用的语言组织方式是这样的:

    描述现状

    要解决的问题

    关于解决这个问题的想法(如果有的话)

    任务大小最好控制在上面的描述可以在你能接受的范围内只用键盘完成输入,这样做的底层逻辑是人想要偷懒,会尽量少地用键盘打字,从而限制单个任务的大小,如果任务过于复杂,人出于打字上的懒惰,就不得不拆分任务。

    在我的实践下,这样交代任务对于 gemini-2.5-flash 跟 GLM-4.5 来说都会比较清晰且聚焦。

    手动压缩重置上下文

    由于 CC 完全不会有取舍地压缩上下文,所以,当我们准备开启一次新的任务时,最好手动压缩(/compact)或重置(/clear)上下文,这样做可以节省 Token 用量。

    何时压缩呢?当我们接下来的任务在代码文件上有切换,且跟前文有比较大的关联,但前文的要求没有被记录在 claude.md 或者 memory 中,压缩上下文就是比较好的选择。
    何时重置?当开始一个完全没有关联的新的任务时,我们可以选择重置上下文。

    模型能力对 Claude Code 的影响

    目前我中强度地测试了两种模型:GLM-4.5 跟 Gemini-2.5-Flash,前者价格只有 Claude Sonnet 4 的 1/10,后者更是只有 Claude Sonnet 4 的 1/50。

    从 Aider 的评测可以看到 claude sonnet 4 跟 gemini 2.5 flash 的性能差异并没有很大。

    而在实际的使用体验中,两者在修复构建脚本、CI 配置文件、升级 monorepo nx 的任务中,GLM-4.5 跟 Gemini-2.5-Flash 表现没有太多差异,都表现出了完整的 PDCA 行为,而且在细节执行层面也都表现出了一定的「倔强」性格。

    适合 Claude Code 的任务

    那些可以只在命令行完成验证任务最适合 claude code,这样 claude code 就可以通过执行命令的方式,验证任务是否完成,并且自动地在验证失败时调整解决方案。

  • 了解非加密哈希算法

    原始讨论见: https://v2ex.com/t/1145673

    我问 Gemini:

    帮助我理解下面的讨论:

    A:

    总共有关联 6 个问题,没找到答案,于是想一起问。

    1. 对于>256 字节的文件,对全文和尾部 128 位字节分别进行 xxHash3-128 然后拼接得到 256bit 散列和 SHA256 一次得到的 256bit 哪个更抗碰撞?
    2. 用于命名用户上传的文件,另外加盐用拼接还是 XOR 方式更好?
    3. 如果拼接的话,拼接在开头是不是要把原文 byte[]复制一遍,影响性能?
    4. 我知道应用于签名场景的话不能拼接在末尾,因为 SHA256 可以向末尾追加块,但是应用于标题这个场景是否安全(会增加暴露盐值让 hash 值可预测)?
    5. 如果通过将原文头部和盐 XOR 运算效果会不会更好?
    6. xxHash3 的 seed 可以代替盐吗?

    B:

    1. 把 xxHash3-128 和 SHA-256 对比还挺滑稽的,因为 xxHash3 从未为密码学设计,而安全性是全称命题(感性认知上属于不刻意设计就很可能不存在的性质)。
    2. 不理解什么是用 XOR 加盐,但我能想象的最简单的方式( XOR 消息之后散列,或者 XOR 散列值,或者两者都做)都很糟糕。
    3. 大多数 hash 算法都支持流式喂送数据,你只要调用两次名字类似 AppendData / Update 的方法就行了。
    4. 不太确定你想象中的“签名”是什么。如果你说的是 HMAC ,首先这是 MAC 而不是签名,然后 HMAC 和简单 hash 区别很大。
    5. 见 3.
    6. 见 1.
      另外,盐是散列函数索引 (hash key/index, or “seed” sometimes) 的实践表现,每个散列函数使用独立的 seed 从可证明安全角度自然没有“再加盐”的必要。

    Gemini 回答:

    好的,我们来深入解析一下这段对话。

    这段讨论的核心是:用户 A 试图通过“自己动手”的方式,将一个非加密哈希算法 (xxHash) 改造得“足够安全”,用于文件命名。而用户 B 则以一个更专业的安全视角,指出了这种做法的根本性错误和危险性。

    简单来说,A 在问“我该如何用木头造一辆跑车?”,而 B 在回答“你不应该用木头造跑车,你应该直接去买一辆真正的跑车。”

    下面我将逐条为你详细解释,并补充一些背景知识。


    核心思想解析

    • 用户 A 的目标: 为用户上传的文件生成一个唯一的、抗碰撞的、256位的标识符(文件名)。他希望兼顾 xxHash速度SHA256安全性
    • 用户 A 的方法: “自己动手,创造密码学”(Roll Your Own Crypto)。他想通过拼接、XOR、加盐等方式,把一个不安全的东西变得安全。
    • 用户 B 的观点: 这是密码学中的第一大忌。安全性是一个系统性的、需要严格数学证明的属性,不是通过简单的“混合”就能得到的。使用未经安全设计的算法来处理安全问题,从一开始就错了。

    逐条问题详解

    问题 1: xxHash3 拼接 vs. SHA256,哪个更抗碰撞?

    • A 的提议: hash = xxHash3_128(全文) + xxHash3_128(文件尾部)
    • B 的回答: “把 xxHash3-128 和 SHA-256 对比还挺滑稽的…安全性是全称命题。”
    • 专家解读:
      • B 的回答一针见血。这个问题的比较本身就没有意义。
      • SHA256 的抗碰撞性是密码学级别的。意思是,即使有一个拥有巨大算力的恶意攻击者,想主动找出两个不同的文件能产生同一个 SHA256 值,在计算上也是不可行的。
      • xxHash 的抗碰撞性是统计学级别的。意思是,对于随机的、非恶意的输入,它能很好地将它们散列到不同的值,以避免“意外”碰撞。但如果一个攻击者知道你用了 xxHash,他可以非常轻松地、在几毫秒内构造出无数个能产生相同 xxHash 值的文件。
      • A 的拼接方案安全性如何?它的安全性等同于其最薄弱的一环,也就是 xxHash。 攻击者完全可以忽略你的拼接手法,直接针对 xxHash 的弱点进行攻击。因此,A 的方案完全不具备密码学上的抗碰撞性

    问题 2 & 5: 加盐用拼接还是 XOR?

    • A 的提议: hash(盐 + 原文) 还是 hash(盐 XOR 原文)
    • B 的回答: “不理解什么是用 XOR 加盐…(但)都很糟糕。”
    • 专家解读:
      • B 的直觉是完全正确的。在密码学中,处理输入数据的标准方式是拼接,而不是 XOR。
      • 拼接 (Concatenation): hash(salt + message) 是相对标准和安全的方式(尤其是在盐是固定长度且放在前面时)。
      • XOR: hash(salt XOR message) 是非常危险的。如果 saltmessage 短,它会被重复使用,产生密码分析上的弱点。更糟糕的是,如果攻击者能以某种方式控制部分 message,他或许能抵消掉 salt 的作用。例如,如果文件的开头是一段固定的、已知的头部信息,攻击者就可以通过 XOR 操作推断或抵消盐值。永远不要用 XOR 来“混合”盐和消息。

    问题 3: 拼接的性能问题

    • A 的担忧: salt + message 是不是要把 message 的字节数组完整复制一遍,影响性能?
    • B 的回答: “大多数 hash 算法都支持流式喂送数据,你只要调用两次…Update 的方法就行了。”
    • 专家解读:
      • B 的回答是 100% 正确的,并指出了一个关键的工程实践。
      • 所有设计良好的哈希库都提供流式(Streaming)API。你不需要在内存里把盐和整个文件拼接成一个巨大的新对象。
      • 操作流程是这样的:
        1. 初始化一个哈希上下文(hasher)。
        2. 向 hasher “喂”入盐 (hasher.update(salt))。
        3. 接着向同一个 hasher “喂”入文件的内容,可以一块一块地喂,不需要一次性读入内存 (hasher.update(file_chunk_1), hasher.update(file_chunk_2), …)。
        4. 最后,从 hasher 中获取最终的哈希结果 (hasher.digest())。
      • 这种方式内存效率极高,无论文件有多大。

    问题 4: 盐放在末尾与签名

    • A 的担忧: 知道 hash(message + salt) 对于签名场景不安全(因为长度扩展攻击),那么在文件命名场景下是否安全?
    • B 的回答: “不确定你想象中的‘签名’是什么…如果你说的是 HMAC…区别很大。”
    • 专家解读:
      • A 了解到一个真实的攻击——长度扩展攻击 (Length Extension Attack),这值得肯定。这种攻击针对 hash(secret + message) 这种结构。
      • B 再次纠正了 A 的术语和理解。A 想要的不是“数字签名”(通常基于非对称加密),而是消息认证码 (MAC),它用于验证消息的完整性和真实性。
      • HMAC (Hash-based MAC) 是解决这个问题的标准方案。它的结构是 hash( (key XOR opad) + hash( (key XOR ipad) + message) )。这个复杂的结构就是为了彻底防御长度扩展攻击等问题。
      • 对于 A 的文件命名场景,虽然长度扩展攻击本身可能不是直接威胁,但这再次暴露了他试图“自己动手”解决一个已有标准方案的问题。正确做法就是使用 HMAC-SHA256。

    问题 6: xxHash3 的 seed 可以代替盐吗?

    • A 的提议: 用 xxHash 自带的 seed 功能是不是就不用自己加盐了?
    • B 的回答: “见 1…盐是散列函数索引…的实践表现…使用独立的 seed…没有‘再加盐’的必要。”
    • 专家解读:
      • B 的回答分为两层:
        1. “见 1”: 既然 xxHash 本身不安全,讨论如何“安全地”使用它的 seed 就没有意义了。这是根本问题。
        2. 但是,从理论上讲:B 的第二部分解释非常到位。在一个设计良好的哈希函数中,seedkey 参数就是实现“加盐”功能的正确方式。它被设计为算法初始状态的一部分,比手动在外部拼接更安全、更高效。
      • 所以,如果我们在使用一个密码学安全的哈希函数(比如 BLAKE3),那么使用它自带的 key 参数就是最理想的“加盐”方式,不需要再手动拼接。A 的问题本身是好的,只是用错了对象。

    总结与正确方案

    用户 A 的所有问题都源于一个错误的前提:试图用非安全工具解决安全问题。

    解决 A 的原始需求的正确方案是什么?

    他需要一个快速、安全、能加盐的哈希算法来为文件命名。

    方案一:行业标准 (The Gold Standard)

    使用 HMAC-SHA256。它绝对安全,被广泛应用和审查。速度对于单个文件命名来说完全足够。

    # 'secret_key' 应该是你保密的、随机生成的长字符串
    # 'input_file' 是用户上传的文件
    openssl dgst -sha256 -hmac "secret_key" input_file

    方案二:现代、高性能的选择 (The Best of Both Worlds)

    使用 BLAKE3。这是一个现代的加密哈希算法,它:

    1. 极其快速:速度与 xxHash 相当,远超 SHA256。
    2. 密码学安全:由顶级密码学家设计,安全性与 SHA-256 同级。
    3. 内置 keyed hashing 模式:这正是 A 想要的 seed 功能,是实现“加盐”的完美方式。

    这完美地满足了 A 对速度和安全性的所有要求。

  • 排查 duckdb-node-neo 并发查询时的随机段错误

    问题的说明跟复现请看这里,这篇文章主要记录一下如何排查 node addon 代码引发的问题。

    保存进程崩溃的翻车现场

    在 Linux/KDE 环境下,当应用进程崩溃的时候,systemd 会记录下该进程的翻车现场(aka,coredump)。我们可以使用 coredumpctl 这个工具查看当前机器上发生过的 coredump。

      List all captured core dumps:
    
          coredumpctl
    
      List captured core dumps for a program:
    
          coredumpctl list program
    
      Show information about the core dumps matching a program with `PID`:
    
          coredumpctl info PID
    
      Invoke debugger using the last core dump:
    
          coredumpctl debug
    
      Invoke debugger using the last core dump of a program:
    
          coredumpctl debug program
    
      Extract the last core dump of a program to a file:
    
          coredumpctl --output path/to/file dump program
    
      Skip debuginfod and pagination prompts and then print the backtrace when using `gdb`:
    
          coredumpctl debug --debugger-arguments "-iex 'set debuginfod enabled on' -iex 'set pagination off' -ex bt"

    还原翻车现场

    通过上面的步骤获取到 coredump 后,可以搭配 debugger 工具跟 symbol file 定位出问题的代码。我选择的 debugger 工具是 gdb 搭配 CLion 作 UI。这样只要将 node addon 项目用 CLion 打开,并导入 coredump 后,就可以获得一个功能齐全的 GUI Debugger。

    用 CLion 打开 node addon 项目

    以 duckdb-node-neo 为例,这个项目使用 node-gyp 作为构建工具,而 CLion 只支持 CMAKE 工程,所以需要手动创建一份 CMakefile 文件,让 IDE 的智能提示正常工具,方便在 debug 的过程中查看定义、搜索引用等。

    下面的这份 CMake 文件是我跟 Claude 一起编写的,不一定能够通过 IDE 编译 node addon,但是可以提供智能补全。

    cmake_minimum_required(VERSION 3.10)
    project(duckdb_node_neo)
    
    set(CMAKE_CXX_STANDARD 17)
    
    # include node headers
    include_directories(/usr/include/node)
    
    # Node.js addon API headers
    include_directories(${CMAKE_SOURCE_DIR}/bindings/node_modules/node-addon-api)
    
    # DuckDB headers
    include_directories(${CMAKE_SOURCE_DIR}/bindings/libduckdb)
    
    # Add source files
    file(GLOB SOURCE_FILES
        "bindings/src/*.cpp"
    )
    
    # This is just for CLion's code intelligence - not for actual building
    add_library(duckdb_node_bindings SHARED ${SOURCE_FILES})
    
    # Define preprocessor macros used in the code
    add_definitions(-DNODE_ADDON_API_DISABLE_DEPRECATED)
    add_definitions(-DNODE_ADDON_API_REQUIRE_BASIC_FINALIZERS)
    add_definitions(-DNODE_API_NO_EXTERNAL_BUFFERS_ALLOWED)
    
    # Add Node.js specific definitions to help CLion's IntelliSense
    add_definitions(-DNAPI_VERSION=8)
    
    # Tell CLion to treat this as a Node.js addon project
    set_target_properties(duckdb_node_bindings PROPERTIES
        CXX_STANDARD 17
        PREFIX ""
        SUFFIX ".node"
    )

    生成带有 symbol 的二进制

    设置后 debugger 后,还需要提供一个 symbol 文件,帮助 debugger 定位代码位置。对于 node-gyp 而言,只需要在编译时设置 --debug 参数即可生成带有 symbol 的二进制。

    cross-replace node-gyp configure --verbose --arch=$TARGET_ARCH --debug && node-gyp build --verbose --arch=$TARGET_ARCH --debug

    接下来我们只要用带有 symbol 的二进制复现进程崩溃的问题,就可以得到一个方便 debug 的 coredump。

    定位问题

    通过 debug coredump 文件,可以很容易找到抛出错误的地方,但这次很不走运的是, error 变量是 NULL,没有提供任何有价值的信息。另外,可以发现 result_ptr_ 似乎处于一个没有被初始化的状态。

    查看 duckdb 的源码后发现,虽然 duckdb 对外提供的是 C API,但是其本身是用 C++ 开发的,在将 C++ 操作以 C API 的形式暴露时,duckdb 会捕获内部的异常,然后将其中的错误信息写入到 duckdb_result 中。

    目前 coredump 表明 result_ptr_ 未被 duckdb 正常初始化,而异常对象很可能已经在跳出 catch 作用域时被销毁,现在只能在运行时 debug 才能捕获到具体的错误信息了。

    附加到进程上 debug

    CLion 提供了非常好用的附加到未启动进程上 debug 的功能,只需要向 CLion 提供进程的 commandline,它就可以自动将 debugger 附加到该进程上。

    在开始 debug 之前,还需要设置一下 C++ 异常断点:

    当启动问题进程,并复现崩溃后,就可以在 debugger 视图查看到实时的异常对象信息:

    这里的 obj 就是异常对象,但是由于 CLion 没有识别出异常的类型,我们得手动用 gdb 指令查看 obj 的信息。

    通过 gdb 的输出,可以看到异常对象的类型是 duckdb::InvalidInputException

    这时在变量页面输入下面的指令就行:

    *(duckdb::InvalidInputException*)obj

    终于,更加具体的错误信息被挖掘出来了~

    后续

    这个问题的根因是 duckdb 不支持在同一个 connection 对象上执行高并发查询,如果需要高并发,则应该为每个查询单独创建 connection,否则将会随机抛出异常 InvalidInputException

    @duckdb/node-api 没有正确初始化异常捕获对象,造成了野指针问题,这才让整个进程在异常报错出后产生段错误崩溃。

  • 早知道 zswap 这么配置就好了

    swap 对于 Linux Server 来说可能是一剂毒药,但对于我这种经常要打开几十个 Tab 的 Linux Desktop 用户来说则可能是救命稻草。网上关于 swap 的调优众说纷纭,我决定给这个充满争议的话题再添一些乱。

    简单来说,我发现使用完全不写入硬盘的 zswap 搭配一个 swap 文件(或分区)要比单纯只用 swap + zswap 要更加适合日常办公场景。

    zswap 是一种 swap 跟 RAM 中间的一个压缩池。当一块内存将被送进 swap 的时候,zswap 会将其先压缩然后放进一个单独的内存池中,而不是直接放进 swap 设备。当 zswap 内存池快满或者这块内存不适合压缩(比如压缩后体积变大了)的时候,zswap 则会将其直接放进 swap 设备。

    通过安装 aur/zswap-disable-writeback ,可以几乎禁用掉 zswap 将内存解压写入 swap 的行为,这时 zswap 就会像 zram/swap 一样工作——仅压缩内存到内存池,不写入到更慢的 swap 设备,例如,硬盘分区或者 swap 文件。

    这样做的好处就是 swap 设备可以空出来用于系统休眠,而且 zswap 仍可以给操作系统提供更多的内存空间,更高效的使用内存。

    比如,在一个有 32GB RAM 的设备上,默认预留 20% 的空间来创建 zswap 的内存池,假设压缩比在 4.0 左右,那么这台设备在理论上可以给应用程序分配的内存将高达 51.2GB ,立省一条 16GB 内存

    在上面的配置下,还可以将 swappiness 设置到 100 以上,让操作系统更加激进地将内存交换出 RAM,给当前正在运行的活跃操作空出更多的空间,以应对突发性的内存需求(比如,打开新的浏览器 Tab)。

    相较于只使用硬盘作为 swap 设备,禁用 writeback 的 zswap 还有经济性上的优势,只要我们的内存需求在大多数情况下能够被 zswap 的内存池满足(在上面的例子中大约是 51GB),就不会发生由 swap 引起的硬盘 IO,有助于延长硬盘的寿命。

  • 在机械革命无界 15XPro 暴风雪上运行 Linux

    This entry is part 1 of 4 in the series 机械革命无界 15XPro

    最近把工作电脑换成了机械革命无界 15XPro 暴风雪,在收货后小小折腾了一两天后,终于让他大部分的功能工作正常了。

    键盘背光跟散热风扇的驱动

    可以参考这篇文章中的介绍安装来自德国一家公司维护的驱动。驱动安装好后,还可以安装类似鸡哥控制面板的 TUXEDO 控制面板,切换性能模式跟调整键盘背光 RGB:

    内置屏幕掉帧

    笔记本内置屏幕在 60Hz 时会频繁随机掉帧,这个现象跟 VRR 有关,需要强制启用 VRR。

    除此之外我还遇到了使用 Spectacle 截图时,外置显示器闪烁的问题,需要强制关闭外置显示器的 VRR。

    睡眠(suspend)后被立即唤醒

    当通过睡眠按键(Fn + F1)或者 systemctl suspend 进入睡眠状态时,系统会被立即唤醒,需要第二次尝试才能成功睡眠。

    反复阅读 https://wiki.archlinux.org/title/Power_management 系列文章后,我在 deepseek-r1 的帮助下成功地找出了这个问题的根因。

    TLDR

    创建一个 udev rule,禁用 PS/2 键盘的 Wakeup Trigger。

    # sudoedit /etc/udev/rules.d/99-disable-keyboard-wakeup.rules
    # Disable wakeup for PS/2 keyboard controller
    ACTION=="add", SUBSYSTEM=="serio", KERNEL=="serio0", ATTR{power/wakeup}="disabled"

    然后重载 udev rules:

    sudo udevadm control --reload-rules
    sudo udevadm trigger

    重启系统后验证是否生效:

    $ cat /sys/devices/platform/i8042/serio0/power/wakeup
    disabled

    现在尝试睡眠,就不会被立即唤醒了。

    排查过程

    首先安装 amd-debug-tools,运行 amd-s2idle test ,查看当前系统是否满足 s2idle 的要求:

    $ amd-s2idle test
    💻 AMD Ryzen AI 9 H 365 w/ Radeon 880M (family 1a model 24)
    💻 MECHREVO WUJIE Series (STX\KRK)
    🐧 Arch Linux
    🐧 Kernel 6.12.32-1-lts
    🔋 Battery BAT0 (OEM standard) is operating at 100.00% of design
    ✅ ASPM policy set to 'default'
    ✅ GPIO driver `pinctrl_amd` available
    ✅ PMC driver `amd_pmc` loaded (Program 11 Firmware 93.4.0)
    ✅ USB3 driver `xhci_hcd` bound to 0000:65:00.4, 0000:67:00.0, 0000:67:00.3, 0000:67:00.4
    ✅ USB4 driver `thunderbolt` bound to 0000:67:00.6
    ✅ System is configured for s2idle
    ✅ GPU driver `amdgpu` bound to 0000:65:00.0
    ✅ PC6 and CC6 enabled
    ✅ SMT enabled
    ✅ IOMMU properly configured
    ✅ ACPI FADT supports Low-power S0 idle
    🚦 Logs are provided via dmesg, timestamps may not be accurate over multiple cycles
    ✅ LPS0 _DSM enabled
    ✅ WLAN driver `mt7921e` bound to 0000:62:00.0
    ❌ Kernel is tainted: 12288
    💯 Your system does not meet s2idle prerequisites!
    🗣️ Explanations for your system
    🚦 Kernel is tainted
    A tainted kernel may exhibit unpredictable bugs that are difficult for this script to characterize. If this is intended behavior run the tool with --force. 
    For more information on this failure see:https://gitlab.freedesktop.org/drm/amd/-/issues/3089

    可以看到这里提示我的 kernel 被 tainted 了,这是安装 mechrevo-drivers-dkms 引起的,可以忽略。

    既然系统符合 s2idle 的要求,说明可能是某个设备在唤醒系统,deepseek 给出了接下来的排查思路,启用 PM 日志:

    # Enable verbose PM debugging
    echo 1 | sudo tee /sys/power/pm_print_times<br>echo 1 | sudo tee /sys/power/pm_debug_messages
    # Now try to suspend again
    systemctl suspend
    # Check dmesg after resume
    dmesg | grep -i "wake\|resume\|acpi"

    再次执行 systemctl suspend 复现问题后,可以在 dmesg 中看到下面的日志:

    [ 4636.873132] xhci_hcd 0000:67:00.4: PM: pci_pm_suspend_noirq returned 0 after 42252 usecs  
    [ 4636.873146] thunderbolt 0000:67:00.6: PM: pci_pm_suspend_noirq returned 0 after 42269 usecs  
    [ 4636.873169] pcieport 0000:00:08.3: PM: calling pci_pm_suspend_noirq @ 29723, parent: pci0000:00  
    [ 4636.878632] pcieport 0000:00:02.1: PM: pci_pm_suspend_noirq returned 0 after 10561 usecs  
    [ 4636.878653] pcieport 0000:00:03.2: PM: pci_pm_suspend_noirq returned 0 after 11025 usecs  
    [ 4636.885192] pcieport 0000:00:08.1: PM: pci_pm_suspend_noirq returned 0 after 12279 usecs  
    [ 4636.885300] pcieport 0000:00:08.3: PM: pci_pm_suspend_noirq returned 0 after 12119 usecs  
    [ 4636.885359] PM: noirq suspend of devices complete after 54.690 msecs  
    [ 4636.885376] ACPI: _SB_.PCI0.GPP5: LPI: Constraint not met; min power state:D1 current power state:D0  
    [ 4636.885381] ACPI: _SB_.PCI0.GPP6: LPI: Constraint not met; min power state:D1 current power state:D0  
    [ 4636.885388] ACPI: _SB_.PCI0.GPP4.SDCR: LPI: Constraint not met; min power state:D3hot current power state:D0  
    [ 4636.886012] PM: Triggering wakeup from IRQ 9  
    [ 4636.886504] ACPI: _SB_.PEP_: Successfully transitioned to state screen off  
    [ 4636.887420] ACPI: _SB_.PEP_: Successfully transitioned to state lps0 ms entry  
    [ 4636.887625] ACPI: _SB_.PEP_: Successfully transitioned to state lps0 entry  
    [ 4636.888555] PM: suspend-to-idle  
    [ 4636.888592] ACPI: EC: ACPI EC GPE status set  
    [ 4636.888623] ACPI: PM: Rearming ACPI SCI for wakeup  
    [ 4636.891279] PM: Triggering wakeup from IRQ 1  
    [ 4639.593124] amd_pmc: SMU idlemask s0i3: 0xffff1abd  
    [ 4639.593188] ACPI: PM: Wakeup unrelated to ACPI SCI  
    [ 4639.593189] PM: resume from suspend-to-idle  
    [ 4639.594699] amd_pmc AMDI000A:00: Last suspend didn't reach deepest state  
    [ 4639.595185] ACPI: _SB_.PEP_: Successfully transitioned to state lps0 exit  
    [ 4639.595760] PM: Triggering wakeup from IRQ 9  
    [ 4639.596746] ACPI: _SB_.PEP_: Successfully transitioned to state lps0 ms exit  
    [ 4639.597719] ACPI BIOS Error (bug): Could not resolve symbol [_SB.ACDC.RTAC], AE_NOT_FOUND (20240827/psargs-332)  
    [ 4639.597729] ACPI Error: Aborting method _SB.PEP._DSM due to previous error (AE_NOT_FOUND) (20240827/psparse-529)  
    [ 4639.597737] ACPI: _SB_.PEP_: Failed to transitioned to state screen on

    Deepseek 大胆推断是 IRQ 1 (typically the i8042 keyboard controller) 造成的问题,可以通过下面的方法来验证:

    echo "disabled" | sudo tee /sys/devices/platform/i8042/serio0/power/wakeup

    重试睡眠后,系统果然安然入睡了~

    进入 s2idle 睡眠后,一晚上大概会消耗 10% 左右的电量。

    Firefox 播放视频时会触发 GPU 图形崩溃

    当启用 Firefox 的硬件加速后,播放视频会让 GPU 崩溃,丢失全部的显存内容。还好这个问题已经被 AMD 修复,升级到最新的内核即可。

    色彩管理

    机械革命提供了官方的色彩文件,但必须要在 Windows 下安装机械革命控制台才能下载(官方客服并不受理此类咨询)。加载了官方的色彩配置文件后,可以比较明显地缓解低亮度下对比度过低的问题。为了方面读到这篇文章的你,点击下面的链接就可以下载到这份色彩配置文件~

    ipfs://QmaR5ZxFXNwcsjn9XZq6CpDfRFyoffDEr9fpi15Hmb31K7

    开机后有概率屏幕卡死

    开启自适应同步后可解决,感谢 LY 提供的解决方案。

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

    This entry is part 4 of 7 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 7 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 的速度还是能达到家庭宽带的上限,网络延迟也还不错,问题终于解决了~