5
12
2026
3

自定义系统默认中文字体

一开始使用Linux系统的时候,并没有多少自由开源的中文字体。那时候几乎所有人的选择都是文泉驿正黑。我就一直用啊用了好多年,直到后来截图时被网友说该换字体了,我才知道原来文泉驿项目已经停止很久了,网站上的新闻截止于2008年——都快20年啦。

文泉驿正黑的字形比较「旧时代」——以屏幕清晰度为优先,由于当时的屏幕普遍dpi低,笔画迁就像素风格,所以比较丑。另外也有些bug,比如「撨䑾詺㘃㞈㟯㫥」这几个字会有一片漆黑的区域。

文泉驿正黑看起来是这样的。注意截图中只有除标题外的中文部分用的是文泉驿正黑字体。

文泉驿正黑渲染的网页

2014年,Google联合Adobe发布了思源黑体和Noto Sans CJK字体——这两款字体的汉字部分是相同的,区别只在于思源黑体会根据文本的区域设置来自动选择字形(通常不管使用哪个语言的字族名来指定),而Noto Sans CJK字族名只有英文版本,并且不同的字族名后缀会选择不同的地区字形。

我在2024年终于决定切换到思源黑体试试。它长这样:

思源黑体渲染的网页

图中的日文部分也是使用的思源黑体,只不过是日文字形。标题则使用的是思源宋体。读者可以在图片上点「右键」然后新建标签页打开图像,然后来回切换着对比。

可以看出,思源黑体是比文泉驿正黑好看多啦。而且思源字体有粗体版本,文泉驿正黑是没有粗体的,只有合成出来的所谓「伪粗体」。

但是,你有没有发现有什么地方不对劲?注意看大标题下方那行字,「条目」和「阅读」下边的装饰线与该行下方的分隔线有一段距离,而「大陆简体」和「工具」两处字偏低。事实上,思源黑体的问题远不止这些。思源黑体文字上方空出来的空间比下方多不少,造成行高太高、终端里文字不居中等各种问题。

思源黑体的布局

(这个图片是使用命令pango-view --dpi=1024 --font=思源黑体 --annotate=glyph,layout,baselines -t A测试中文Test -o out.png生成的。)

因此我用了几天就换回文泉驿正黑了,但是把思源黑体作为部分网页字体在用(主要用于非简体中文内容,以及通过stylus指定的特定几个网站)。

最近,oldherl说更纱黑体修了行高的问题。于是我又试了几天这个基于思源黑体的字体。它的效果是这样的:

更纱黑体渲染的网页

行高的问题确实解决了!但是——又要「但是」了——它的字怎么矮胖矮胖的?是我没看习惯的原因吗?于是我使用了几天,最后并没有习惯,反而是和思源黑体一对照,发现确实是许多字都变矮了一点。

于是我只好又换回已经用习惯了的文泉驿正黑。

诶等等!既然更纱黑体能修行高,我为什么不行?因为我不会,可是,今非昔比了呀——Gemini,给我来个脚本!

于是就有了这么个脚本——当然这个脚本是我改过的最终版本了。

#!/usr/bin/python3

from io import BytesIO

from fontTools.ttLib import TTCollection, TTFont

def adjust_font(font):
    target_ascent = 1025
    target_descent = -265
    
    # 修改 hhea 表 (macOS/Pango 渲染常用)
    font['hhea'].ascent = target_ascent
    font['hhea'].descent = target_descent
    font['hhea'].lineGap = 92

    # 修改 OS/2 表 (Windows/Linux 合规性)
    font['OS/2'].sTypoAscender = target_ascent
    font['OS/2'].sTypoDescender = target_descent
    font['OS/2'].sTypoLineGap = 92
    # usWin 参数决定了红线(剪切区域),设为相同值可消除额外间距
    font['OS/2'].usWinAscent = target_ascent
    font['OS/2'].usWinDescent = abs(target_descent)

    # 2. 修改 Font Family 名称
    name_table = font['name']
    for record in name_table.names:
        name_str = record.toUnicode()
        new_record_str = name_str.replace('Source Han', 'Lily Han') \
                .replace('思源', '百合') \
                .replace('源ノ角ゴシック', '百合ノ角ゴシック') \
                .replace('본고딕', '백합고딕') \
                .replace('SourceHan', 'LilyHan')
        # 针对不同 ID 进行替换
        # ID 1: Family Name, ID 4: Full Name, ID 6: PostScript Name 等
        if name_str != new_record_str:
            name_table.setName(new_record_str, record.nameID, record.platformID, record.platEncID, record.langID)

    # Medium as Semibold
    if font['OS/2'].usWeightClass == 500:
        # save then read to copy the font without referencing existing data structures
        buf = BytesIO()
        font.save(buf)
        buf.seek(0)
        sb_font = TTFont(buf)

        sb_font['OS/2'].usWeightClass = 600

        for record in sb_font['name'].names:
            name_str = record.toUnicode()
            if "Medium" in name_str:
                new_name = name_str.replace("Medium", "Semibold")
                record.string = new_name.encode(record.getEncoding())

        cff = sb_font['CFF '].cff

        for i in range(len(cff.fontNames)):
            if "Medium" in cff.fontNames[i]:
                cff.fontNames[i] = cff.fontNames[i].replace("Medium", "Semibold")
        
        # 修改 TopDict 内部的名称
        for topDict in cff.topDictIndex:
            for attr in ['FullName', 'FamilyName', 'Weight']:
                if val := getattr(topDict, attr):
                    if isinstance(val, str) and "Medium" in val:
                        setattr(topDict, attr, val.replace("Medium", "Semibold"))

        return sb_font

def main(input_ttc, output_ttc):
    ttc = TTCollection(input_ttc)
    new_fonts = list(ttc.fonts)
    for font in ttc.fonts:
        newfont = adjust_font(font)
        if newfont:
            new_fonts.append(newfont)
    ttc.fonts = new_fonts
    ttc.save(output_ttc)
    print(f"成功保存至: {output_ttc}")

if __name__ == '__main__':
    import nicelogger
    nicelogger.enable_pretty_logging('DEBUG')
    input_ttc = "SourceHanSans.ttc"
    output_ttc = "LilyHanSans.otc"
    main(input_ttc, output_ttc)

脚本使用fonttools这个Python库,把ascent、descent和lineGap这三个参数改成和文泉驿正黑一样的了。之所以要照着文泉驿正黑来改,是因为我的终端最大化之后,使用13pt字号差不多刚好填满35行(只空出来两行像素)。而使用别的参数,我调整字号好久,都会空出来小半到大半甚至接近一行文字的高度。

另外,这个脚本运行起来非常耗资源:它保存的时候会重新计算每一个字形,持续占用一个CPU核心长达六分多钟,内存只分配不释放,最终用掉接近20GiB。倒是生成的文件差异不大,用rsync很快就能同步回来。

最终效果图:

百合黑体渲染的网页

可惜的是思源黑体只有粗体和Medium,不像更纱黑体那样有半粗(Semibold)版本。思源黑体的粗体挺粗的。

由于我在使用思源黑体时遇到的问题都是在UI部分,而这里又不需要用到宋体,所以我没有去改思源宋体,其它地区的字形也还是用的原本的思源系列。

如果有人想试试我生成的这个字体的话,在这里下载(112 MiB)。


2026年05月13日更新:更新了脚本和字体文件。现在把Medium复制一份然后改叫Semibold了,因此有了半粗字重,在用到的地方会更接近设计者的意图,也看着更舒服。

Category: 中文支持 | Tags: 字体 中文支持
4
18
2026
0

Wayfire支持不缩放Xwayland啦

Wayland协议会告诉客户端程序缩放比率应该是多少,支持的就按指定的来,不支持的Wayland compositor就负责把窗口缩放到合适的大小。但因为是客户端渲染成位图之后,再由Wayland compositor缩放,大小是合适了,但却模糊了起来。然而很不幸地,Xwayland就是那个不支持Wayland缩放机制所以被Wayland compositor强行缩放的Wayland客户端。

但其实在X的时代,各个GUI框架都发展出来了自己的一套指定缩放的机制。比如一些老的程序会认Xft.dpi这个设置,GTK可以用GDK_SCALE环境变量,Qt认QT_AUTO_SCREEN_SCALE_FACTORQT_SCREEN_SCALE_FACTORS,Wine可以在winecfg里设置,等等。我想说的是,虽然不太统一,但大家其实都有自己的HiDPI方案了。

所以,Wayland compositor如果不强行缩放Xwayland的窗口的话,那些还不原生支持Wayland的程序其实也能显示得清晰好看的。但可惜的是Xwayland始终没有合并任何解决方案,我也就只好用着打过补丁的wlroots-lily-gitxorg-xwayland-lily了。KDE很早就引入不缩放Xwayland窗口的选项、让这些使用Xwayland的程序自己处理缩放。Hyprland后来也支持了。而现在,终于轮到Wayfire支持啦~

Wayfire的这个选项叫workarounds/force_xwayland_scaling。设置为true就是程序自己处理缩放、Wayfire不管它了。一开始没处理鼠标光标,后来也修好了。xorg-xwayland-lily终于完成了它的使命~

顺便说一下,workarounds这里我还设置了另外两个选项。一个是discard_command_output=false,这样能看到Wayfire启动的程序的报错信息。另一个是auto_reload_config=false,禁用自动重新加载配置文件,不用和git打架了。

[workarounds]
discard_command_output = false
auto_reload_config = false
force_xwayland_scaling = true
Category: Linux | Tags: X Window X window Wayland wayfire
4
2
2026
1

使用wayvnc远程访问无头Wayfire会话

显示器送去维修了,因此我的台式机变成无头设备啦。现在用它来编译软件是没啥问题的,但是我的GUI软件的窗口全部访问不了啦,编译出来的wayfire也没法调试了。本来已经存在的窗口我打算等显示器修好回来再用的,但是Vim刚出了一个公开利用方式的RCE,我怕我的GVim留在那里忘记了然后不小心中招,直接关又不记得它有没有打开什么需要处理的文件。于是想到了远程桌面。

首先想到的是群友的reframe。之前显示器还在的时候它挺好用的,但是现在显示器不在了,它就连不上去啦。读了文档才知道需要配置一个虚拟显示器,要动内核参数所以需要重启,我的窗口们就回不来啦。另外等显示器回来,要去掉虚拟显示器还要再次重启。

其实我还有另一个显示器,也就是大上的E-ink屏幕。接上去之后,我的wayfire桌面回来了,但是我的窗口并没有……lswin发现它们还在「unknown」显示器上。于是我开始研究Wayfire IPC看看怎么把它们弄回来。

花了些时候,去Matrix频道里问了一下,窗口们是用configure_view找回来了,但是发现我怎么几百个叫「input-method-popup」的窗口啊(其实这就是我要调试Wayfire的原因)。另外发现有创建无头输出的接口,也就是Wayland compositor层级的虚拟显示器啦。

墨水屏用来阅读和写代码挺好的,但是用来测试GUI软件就不太适合了,更不用提打游戏了。所以就想着既然能创建无头输出了,那我是不是可以把无头输出VNC出来、在笔记本屏幕上看?于是读了一下wayvnc的文档,发现还真可以。

创建无头输出:

import wayfire, os
addr = os.environ.get('WAYFIRE_SOCKET', os.environ['XDG_RUNTIME_DIR'] + '/wayfire-wayland-1-.socket')
sock = wayfire.WayfireSocket(addr)
sock.create_headless_output(1920, 1080)

然后记下创建出来的输出的名字(HEADLESS-开头的那个)。几经尝试,最终使用的wayvnc命令是:

wayvnc -f 60 -r -g -o HEADLESS-2 IP PORT

其中-f 60指定帧率,默认是30fps;-r指定渲染光标,否则在服务端使用鼠标时不显示光标;-g启用需要GPU的特性;-o HEADLESS-2是选择之前创建的无头输出。后边的监听地址我指定了与笔记本直连的有线网口的IP,这样不需要设置密码、也不用加密就很安全。

然后使用tigervnc连接:

LANGUAGE=en_US vncviewer -Maximize IP:PORT -PreferredEncoding H.264

vncviewer显示中文有问题,因此使用英文界面。这里指定了编码方式为H.264,希望能比默认更好一些。我原本还想用Raw来着,但是问了一下Gemini,说是1Gbps不够1080p60用,另外序列化延迟也会更高。不过wayvnc和vncviewer都没有使用VAAPI的样子,不知道是不支持还是哪里有问题。

实际占用的带宽最高为27 MiB/s。延迟挺低的,反正我感觉很不明显,玩游戏也没有问题。不过可能是因为有压缩,文字的显示不是很清晰。

哦对了,因为vncviewer是Xwayland软件,因此它自己的键盘捕获是没有用的。我使用Wayfire的shortcuts-inhibit设置让它默认捕获键盘了,需要的时候再按快捷键临时取消一下。

[shortcuts-inhibit]
break_grab = <ctrl> <alt> <shift> KEY_ESC
inhibit_by_default = app_id is "Vncviewer"

至于为什么不用别的VNC客户端,gtk-vnc慢死了,remmina界面好复杂,virt-viewer我不知道怎么叫它连远程VNC。

Category: Linux | Tags: Wayland Wayfire 显示器 网络
11
11
2025
2

给论坛用上了文本嵌入模型

偶然间发现Discourse论坛支持利用文本嵌入模型来生成「相关话题」列表、提供语义化搜索。于是我给Arch Linux中文论坛试过了好几个模型,记录一下经验。

文本嵌入,英文叫「text embedding」,指的是将一段文本编码成语义空间中的向量,从而可以判断不同文本的语义相关性。编码出来的向量少则512维,多的能有4096维。而判断相关性有「余弦距离」(看两个向量的夹角大小)和「负内积」(一个向量和另一个向量的转置相乘,然后取负)两种方法,我都是看模型文档和示例来决定用哪个的。至于这些向量的存储和索引,Discourse使用的是pgvector这个PostgreSQL插件。

Discourse启用这个功能之后,会在每个话题下方推荐几个「相关话题」,很适合看看是不是有人问过相同的问题。语义化搜索则需要在搜索页面点按钮来显示。在搜索框里按两下回车,就能到搜索页面了(这时候语义化搜索就会进行了,虽然用户还看不到结果),或者点搜索框右边的按钮也行。

因为论坛以中文为主,所以没多少可以抄Discourse官方文档的地方。一开始我挑了好几个来尝试,bge-m3、all-mpnet-base-v2、gte-multilingual-base等。但是没想到它们体积不大,但跑起来却很吃资源。E5-2678 v3辛辛苦苦跑了好久,结果去数据库里一看,已索引的话题数量才几个、十几个,而且不见涨……后来写了API转换代理我才知道,原来是因为Discourse会批量并发请求,并发度会高达45左右,于是很容易导致本来就慢的请求因为排队太久而超时被放弃,CPU都白算了。

最终我找到gte-base-zh这个模型,是针对中文特化的。很小,才0.1B,但这CPU跑得动它。效果也还能接受。

后来了解到最近新出的Qwen3-Embedding系列,看评分效果是最好的。又有群友愿意提供显卡算力,于是试了试。

Qwen3-Embedding提供8B、4B、0.6B三种参数规模的模型。8B很重,我的6650XT的8G显存勉强能放下它的Q4_K_M量化版本。0.6B的只有Q8_0的量化版本,我的显卡跑起来轻松不少,就是不知道为什么它占了我4G+的显存,导致剩下的显存不够原神用了。另外运行的时候如果不用systemd的CPUWeight之类的手段降一下CPU优先级,会导致我的桌面也很卡——我没找到调整GPU优先级的方法,不过调整CPU优先级也管用。

这些模型在群友提供的RYZEN AI MAX+ 395上跑得就比较惨。这台设备有算力不错的核显——至少比用Linux的Apple M2 Ultra算得更快一些,也有核显能够使用大量内存的优势,但是!amdgpu驱动会在高负载时崩溃重置!这么久过去了,amdgpu依旧不待见核显啊(不过听说Intel那边新的xe驱动也有不少bug)。不过断断续续跑了几天之后,终于把大部分话题都索引好了。

后来我还是换0.6B模型了,因为群友提供的算力并不稳定,我想要更容易替代的方案。可能Qwen3-Embedding系列模型对我的用途来说实在是太优秀了,以至于不管是0.6B还是8B,我都没发现结果有什么明显的差异。但0.6B对性能的需求低很多,甚至编译机上的7950X3D也能跑——虽然编译机没那么多时间能跑它就是了。

我还尝试过Google家的embeddinggemma-300M模型。它的MTEB评分比gte-base-zh要高,但只比gte-base-zh大一倍。但实际用下来,呃,效果差很多,基本上没啥用,可能分数都得在别的语言上了吧。遂放弃。

目前的论坛文本嵌入算力主要由群友的RYZEN AI MAX+ 395提供。在它不在线的时候,则由另一位群友提供的Apple M2 Ultra编译机兼职。哪天要是它也有事不在了,还能由x86编译机接棒。在历史话题索引完毕之后,平时的请求其实挺少的。

哦对了,最近还接触过一个叫all-MiniLM-L6-v2的模型,超级小,只有22.7M参数,是火狐新加的地址栏语义化搜索用的。但是它只支持英文,对于中文来说纯粹在增加噪音,可以在about:config里搜索places.semanticHistory.featureGate关闭之。

最后说说运行这些模型的方式。对于给sentence-transformers用的模型,可以用ghcr.io/huggingface/text-embeddings-inference:cpu-latest这个容器来运行。缺点是,它只有支持CPU和CUDA的版本。所以我更喜欢找gguf格式的模型,然后用llama.cpp来运行,可以使用Vulkan或者ROCm。不过我测试发现llama.cpp用ROCm还不如用Vulkan的来得快,而ROCm有着极其巨大的依赖库群,我就不用它了。要是乐意用ROCm的话,也可以用ollama来跑,支持动态加载和卸载模型——但这对于长期运行的服务型用途来说并不是很适合,我还得传个参数让它不要一直加载卸载。

9
23
2025
5

使用 Restic 备份数据

我很早就知道 Restic 这个备份工具了,但是因为我有 rsync 和 btrfs send/receive 方案所以一直没用过。某天,突然有个走 AWS s3 协议的服务器备份需求,这才把它翻了出来。

既然是 s3,首先设置环境变量 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY,然后仓库的地址是 s3:域名/存储桶名。好像一个存储桶里只能初始化一个 Restic 仓库?我还不清楚,也没有能给我玩的环境,就不管了。仓库地址可以设置到环境变量 RESTIC_REPOSITORY 中,这样就不用每次用 -r 参数指定了。执行 restic init 进行初始化。Restic 只支持加密备份,所以必须设置一个备份密码——倒也可以设置为空字符串,但是加密仍旧会进行,并且需要额外的参数来允许空密码。密码也可以放在 RESTIC_PASSWORD 环境变量里。

然后就是执行备份命令了,很简单的,比如:

restic backup -x --exclude-caches --exclude='/home/*/.cache/' --exclude=/var/cache/pacman/pkg/'*' ... /

这样就备份了整个系统,并排除了一些目录。可以先加 -v --dry-run 参数测试一遍。确定好备份的参数之后,就可以设定计划、反复执行了:

[Unit]
Description=backup the system
After=network-online.target

[Service]
Type=oneshot
Environment=HOME=/root
EnvironmentFile=/etc/restic.conf
ExecStart=restic backup -x --exclude-caches ... /
[Unit]
Description=backup system daily

[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
AccuracySec=6h
Persistent=true

[Install]
WantedBy=timers.target

仓库地址和各种密码之类的放环境变量文件里了。把这个文件设置为只有 root 才能读,避免别的用户看到不该看的内容。然后 systemctl enable --now sysbackup.timer 就好了。

之后可以用 restic snapshots 查看备份的数据。使用 restic forget 删除旧的备份,比如 restic forget -d 30 -w 10 -m 10 -l 5 在最近30天、10周、10个月中各保留一份,外边最新的五个。这个命令只是删除这个备份的元数据,其关联的数据不会被删除。之后还要执行 restic prune 来实际删除数据——这个过程会比较费时费力。加 -v 可以查看诸如减小了多少空间占用的数据。

Restic 的备份是打包成块、压缩、加密存储的,带去重功能。但是增量备份会和主机名和路径相同的上一个备份进行比较。一个仓库里可以备份多台机器的相同或者不同的目录,但为了有效率地增量备份,每次增量备份时的目录不能变。这最后一点给我出了个难题。

我把 Arch Linux 中文论坛(以及维基)也用 Restic 备份到 sftp 目标里了。但这俩都是重数据库应用,因此直接对着 / 执行 restic backup 不行,数据有可能因为数据库的不同文件来自于不同的时间点而损坏。该系统在 btrfs 上并且有快照,对着快照备份就好了。问题是 Restic 执意不支持设置源路径,只能从文件系统上读取。我只好用 mount 命名空间把快照弄到 / 上再执行备份。脚本在这里:https://github.com/archlinuxcn/misc_scripts/blob/master/wiki/restic-backup-snapshot。由于权限的问题,用 bwrap 是不行的。对快照再做一个固定路径的可写快照来备份倒是能把路径固定到一个指定的位置,就是不太优雅,事后删掉快照也有额外的性能消耗。

关于备份数据的大小,restic snapshots 显示的是所有文件的名义大小的总和——是压缩前的大小,并且硬链接会算多次。restic stats --mode=restore-size 则是按硬链接去重后的未压缩大小。restic stats --mode=raw-data 才是压缩后的、备份实际占用的空间。

Restic 的备份仓库是加密的,因此无法直接查看,但它有 restic mount 子命令可以把备份仓库挂载了查看内容,就是性能比较差,只适合检查和恢复特定的少量文件。恢复大量数据推荐用 restic restore。另有 restic diff 命令可以查看不同快照之间的文件差异(新增、修改、删除了哪些文件,不含内容),方便在大小出现异常时查找元凶。

最后说说配置只能使用 sftp 的用户的方法。

首先在 sshd_config 里设置 Subsystem sftp internal-sftp。因为之后要进行 chroot,访问不到外部 sftp 服务的可执行文件的。然后设置指定的用户组只能用 sftp:

Match Group sftp-users
     ChrootDirectory /srv/sftp
     X11Forwarding no
     AllowTcpForwarding no
     AllowAgentForwarding no
     ForceCommand internal-sftp -d /%u

最后创建用户,按常规设置 authorized_keys,并按之前配置的路径,在 /srv/sftp 下创建该用户与其用户名同名的存储数据的目录,记得要 chown 到该用户。注意 ChrootDirectory 的目标目录(也就是这里的 /srv/sftp)应当只能由 root 写入(就跟 /home 目录那样)。

Category: Linux | Tags: linux 备份 restic
8
26
2025
7

Arch Linux 中文论坛迁移杂记

本篇只是心得体会加上赞美和吐槽。技术性的迁移记录在这里。

一个多月前,肥猫在 Arch Linux 中文群里说:

(希望有好心人研究一下php74,最好加到archlinuxcn里,因为咱中文社区论坛卡在这个老版本了

然后话题自然就又到了论坛迁移的事情上来——毕竟 FluxBB 年久失修也不是一两天的事了。Arch Linux 官方讨论了几年还没结果,但中文社区这边并不是卡在往哪迁上,而是

基于什么的都可以迁移,但得有人干活

然后又过了些天,论坛使用的本就递送困难的 Sendgrid 停止了免费服务,导致中文论坛完全无法注册新用户了。虽然这件事通过更改为使用我们自己的邮件服务器就解决了,但迁移论坛的想法在我脑中开始成长。

至于迁移到哪个软件,我早已有了想法——包括 OpenSUSE 中文社区Debian 中文社区NixOSOpenSUSEFedoraUbuntuManjaroGaruda LinuxCachyOSKDEGNOMEPythonRustAtuinF-DroidOpenWrtLet's EncryptMozillaCloudflareGrafanaDocker 等等(还有一堆我没有那么熟悉的就不列了)大家都在用的 Discourse。这么多开源社区和商业组织都选择了它,试试总不会错的,用户也会比较熟悉。令我惊喜的是,它甚至有个 FluxBB 导入脚本。

于是在虚拟机里安装尝试。安装过程是由他们的脚本驱动构建的 docker 镜像,没什么特别的——除了比较耗资源。Docker 嘛,硬盘要吃好几 GiB,然后它是现场编译前端资源文件,CPU 和内存也消耗不少。默认的构建是包含 PostgreSQL、Redis、Nginx 的,但 PostgreSQL 也可以用外边的,就是得监听 TCP,并且构建出来的镜像里依旧会存在 PostgreSQL 的服务文件。Nginx 可以改成监听 UNIX 域套接字,然后让外边我自己的 Nginx proxy_pass 过来,这样证书也按自己的方式管理。Redis 我本来也是想拆出来的,但是为安全起见要设个密码嘛,然后构建就失败了……不过算了,反正也没别人用 Redis。

本地测的没有问题,于是去服务器上部署。由于发现它比较吃资源,所以给服务器加了几十G内存、100G 硬盘,还有闲置的 CPU 核心也分配上去了。后来发现运行起来其实也还好,就是内存吃得比较多——每个 Rails 进程大几百,32个加起来就快 10G 了。运行起来之后 CPU 不怎么吃,甚至性能比 MediaWiki 要好上不少,以至于我把反 LLM 爬虫的机制给降级到大部分用户都不需要做了。哦它的 nginx 会起 $(nproc) 个,太多了,被我 sed 了一下,只留下了八只(但其实也完全用不上,毕竟是异步的;Rails 那些进程可是同步的啊)。

run:
  - exec: sed -i "/^worker_processes/s/auto/8/" /etc/nginx/nginx.conf

说回部署。跑起来是没什么问题的。问题出在那个 FluxBB 导入脚本——本来导入就不快,它还跑到一半崩了,修了还崩。然后它不支持导入个性签名、用户头像、置顶帖,还遇到著名的 MySQL「utf8 不 mb4」的问题。来来回回修了又改,花了我好些天。等保留数据测试的时候,发现有好多帖子的作者变成「system」了。一查才发现我刻意没有导入被封禁的用户造成的。都已经配得差不多了,实在是不想删库重来,研究了一下,直接在 Rails 控制台写了段脚本更正了。这个 Rails 控制台能在 Rails 的上下文里交互式地执行代码,很方便改数据,我很喜欢,比 PHP 方便太多了!

另外这个导入脚本有一点好的是,它能够反复执行来更新数据——虽然这种支持反复执行的操作在我写的脚本里是常有的事,基本上从头开始成本太高的都会有,但别人写的脚本能考虑到这一点的可太少了。

用户的密码也导入了!帖子的重定向服务也写好了!虽然后来发现用户个人页面是登录用户才能访问的,给它重定向没什么用……反而是 RSS 重定向更有用,后来也补上了!

后来还发现有些用户名包含空格或者特殊符号啥的,被自动改名了。好在可以用邮箱认用户,不管了,等受影响的用户出现了再改。另外正式迁移之后才发现还有些数据没有迁移——版块的对应关系、用户的主题订阅,不是很重要,算了。

然后就迁移结束啦~所有到旧论坛的访问全部重定向到新论坛啦~不过我还是保留了一个不跳转的后门以便有需要时回去看看。为此我折腾了好久神奇的 nginx 的配置文件,最终得到以下片段:

set $up 'redir';
if ($http_cookie ~ "noredir=1") {
    set $up 'noredir';
    proxy_pass https://104.245.9.3;
}
if ($up = redir) {
    proxy_pass http://127.0.0.1:9009;
}

就是根据 cookie 来 proxy_pass 到不同的服务啦。这样就可以访问一下 /noredir 设置上 cookie,就可以访问旧论坛,再访问一下 /yesredir 清一下 cookie 就恢复跳转到新论坛了。

说起 nginx,Discourse 还在这方面坑了我一下。它文档里给的设置是:

proxy_set_header Host $http_host;

这个配置在 HTTP/3 时是坏的,应该用 $host。别问我 HTTP/2 也没有 Host 头啊,为什么它在用 HTTP/2 时就不会出错。我也不知道 ¯\(ツ)/¯。

于是新论坛上线啦~很多中国大陆用户的首次加载时间变成几十秒啦……还好这只是无缓存加载的时间,就当是下载软件了吧。之后每个标签页大约需要一两秒加载整个 SPA,在不同页面之间跳转并不慢。而这代价付出之后的回报是更现代的界面、丰富的功能。相比于旧论坛,现在:

  • 终于有手机版啦,甚至还支持 PWA,体验非常丝滑。
  • 实时预览的 markdown + bbcode 编辑器,还支持上传图片!再也不会有用户问论坛怎么传图片和日志了!
  • 编辑器还支持草稿功能!不用怕写一半弄丢了,甚至可以换个设备接着写。
  • 代码块角落里有个复制按钮,我再也不需要拖半天鼠标来复制日志然后粘贴进 Vim 里分析了!
  • 快速、简单的搜索体验,用户再也不会找不到搜索功能在哪里了!
  • 实时显示在回复的人。有人在回复的时候就可以等一等,发布帖子之后会立刻出现。发帖不需要跳转到不知道干什么的跳转页面了,读帖也不需要反复刷新了。
  • 有收藏功能了,用户不用发一个根本没什么用的「mark 一下」的帖子了。
  • 有「标记为已解决」的功能了。用户再也不需要问怎么把帖子标记为「已解决」了。
  • 不用自己跑脚本解析 HTML 来向群里发新帖通知了。Discourse 的「聊天集成」功能配一下就好了。
  • 甚至还有「RSS Polling」插件,可以把主站新闻转到论坛里,方便大家讨论。

Discourse 的邮件集成功能也挺不错的。配好之后,可以检测到退信,也可以直接回复邮件通知来回帖。甚至还有个邮件列表模式,就是把所有帖子都给用户发一遍,用户也可以直接回帖。通过邮件发布新主题的功能也有,但我没有启用——不同版块需要配不同的收件地址,有点麻烦,我不觉得有人会想用……就是这个邮件传回 Discourse 部分坑了我一把,但不是 Discourse 的错。

是 maddy 的文档太缺欠了。我要把 forum+...@archlinuxcn.org 这种地址给重写到 noreply@archlinuxcn.org,按例子像这样

table.chain local_rewrites {                                                                                                                   
    optional_step regexp "forum\+(.+)@(.+)" "noreply@$2"
    optional_step regexp "(.+)\+(.+)@(.+)" "$1@$3"
    optional_step static {
        entry postmaster postmaster@$(primary_domain)
    }
    optional_step file /etc/maddy/aliases
    step sql_query {
        driver postgres
        dsn "user=maddy host=/run/postgresql dbname=maddy sslmode=disable"
        lookup "SELECT mailname FROM mailusers.mailinfo WHERE $1 = ANY(alias) and new = false"
    }
}

这里第二行是我加的(虽然一开始把 $2 照着下边那个已有的写成了 $3)。结果是报错「用户不存在」、被退信。我开 debug 选项研究了好久,才意识到最后一步写的是 step,所以它总是要执行的——然而 noreply 这个用户并不在数据库里,所以就找不到了。

那把最后一行改成 optional_step 就好啦——我是这么想的,也是这么做的。然后就有人报告说 admin 邮箱拒收邮件了……又是一通研究,才发现因为这里的步骤全是 optional_step,所以 maddy 第一次用整个邮件地址来查的时候,无论如何都是会通过的——不会返回「目标不存在」,所以也就不会触发去掉域名、只用用户名查询的步骤,而数据库里记录的只有用户名,就导致 admin 邮箱的映射查不到了(映射到它本身,然而并没有以它为名的邮箱)。把 SQL 查询那一行改成这样子就好了:

lookup "SELECT mailname || '@archlinuxcn.org' FROM mailusers.mailinfo WHERE regexp_replace($1, '@archlinuxcn.org$', '') = ANY(alias) and new = false"

然后是把收件的邮件交给 Discourse。他们有个 mail-receiver 容器用来干这事,但这个容器的主要部分其实是 Postfix。我读了一下它的代码,实际上只需要把邮件通过 API 发过去就行了。于是我用 Python 写了一个服务——imap2discourse。这部分的坑在于,这个 API 是把邮件全文用指定的参数 base64 或者不 base64 用 form-data 传过去的,我以为是用上传文件的方式来传,搞了半天它都报奇奇怪怪的错,后来一步一步在 irb 里按 mail-receiver 的代码对照检查,才发现原来是按传字符串的方式传的……

Discourse 的中文翻译不怎么样,好多随意的空格,也好多看不懂的翻译。好在它像 MediaWiki,支持修改界面文本。于是就一点一点地修了好多。上游使用的是 Crowdin 翻译平台,并不能直接 pr 翻译,所以等我什么时候研究一下才能把翻译贡献给上游了。

Discourse 的通知功能挺全面的。可以选择回帖之后要不要通知,邮件通知是只在没访问时发、完全不要还是全都要,网站图标上要不要显示通知计数,还可以开启浏览器的推送通知(然后我就发现 Android 火狐的推送通知无法切换到 PWA 窗口)。

至于管理功能,比 FluxBB 丰富好用太多啦~有各种访问统计报表。设置项有搜索功能。有管理员操作日志,也有选项变更日志,还有邮件收发日志(看看谁又把自己的邮箱域名拼错了)。能给用户添加备注,也能切换成指定的用户看看他们看到的论坛是什么样子的。能给用户添加字段,让用户填写他们用的操作系统和桌面环境,省得回帖时经常要询问。还能加载自定义的 JavaScript 和 CSS,甚至是加强版本。还有暂时用不上的 API 和 webhook。哦对了,我发现它还会自己拒绝一些常见的讨厌爬虫。

除了 FluxBB 之外,还有一个叫 planetplanet 的 RSS 聚合软件也是死了好多年,导致 planet.archlinuxcn.org 多年不更新了。Discourse 正好有从 RSS 发帖的功能,于是将星球也复活了一下,将大家的 RSS 作为帖子在专门的版块发出。虽然界面不是很理想,但将就着用啦。RSS 聚合也是有的。Discourse 的 RSS 功能相当完善,几乎在所有合理的网址后边添加 .rss 就能订阅。

也给旧论坛做了个静态存档站。暂时还没上线,因为肥猫又跑掉了

Discourse 的备份功能会报错,因为它的容器里的 pg_dump 版本比较旧,和我在外边 Arch Linux 里运行的版本不一致。不过我觉得这样也挺好的——因为管理员是可以生成和下载备份文件的,也就是说,如果有管理员的权限被人恶意获取,那么他就能通过下载备份文件的方式获取整个 Discourse 数据库的内容。备份不了就少了这么个风险啦。当然备份我肯定是做了的,至于是如何做的,就等下一篇啦。

Category: Linux | Tags: Discourse 社群 web Arch Linux
3
5
2025
3

pacfiles: 高速的 pacman -F 替代品

缘起

Linux 发行版的软件包管理器通常都会提供这么一个功能——查找文件在哪个仓库中存在的软件包里。实现起来也挺简单:仓库维护一个每个软件包里都有哪些文件的数据库,软件去查就可以了——假如用户不介意性能问题的话。

最开始,我使用的是 pkgfile。它是使用 C++ 编写的,会把 Arch 官方提供的 .files 数据库(压缩的 tar 归档)转成 cpio 归档再用(压缩可以靠 btrfs,问题倒是不大)。它比 pacman -F 可快多了,但是我后来不用了,因为它当时不支持多架构——即在 pacman.conf 里把 Architecture 设置为多个值,比如我用的 x86_64 x86_64_v3。现在等我写好了 pacfiles,才发现它终于在大半年之前支持多架构了……不过它看起来开发还是不太活跃,选项和输出格式也和 pacman -F 有很大的差别。

效果对比

最主要的功能是按文件名搜索,因此让我们先看看这个:

pacman -F vim 截图

pkgfile vim 截图

pacfiles -F vim 截图

pacman -F 和 pkgfile 都是遍历整个数据库。pacman -F 和 pacfiles 是单线程的,pkgfile 是多线程,但我不知道为什么 pacman -F 会慢那么多。pkgfile 比 pacfiles 快一些,毕竟它提供的信息少、又不好看、还是多线程并行工作。另外值得注意的是,pacman -F 由于会预先加载整个数据库到内存,因此内存占用了近 3G。

有时候也会想要按完整路径搜索

pacman -F /usr/bin/vim 截图

pkgfile /usr/bin/vim 截图

pacfiles -F /usr/bin/vim 截图

这次 pacfiles 因为有索引的帮助,并且不需要检查软件包是否已安装,比 pkgfile 快了不少。pacman -F 依旧又慢又吃内存。

接下来看看输出软件包的文件列表。这个由于输出结果多、输出格式又都差不多,我就重定向扔掉了,只看性能数据。

pacman -Fl vim-lily 截图

pkgfile -l vim-lily 截图

pacfiles -l vim-lily 截图

这次 pkgfile 比 pacfiles 略快。

有时候也会想用正则搜索

pacman -F --regex '.*libpython3\.11.*'

pkgfile --regex '.*libpython3\.11.*'

pacfiles -F --regex '.*libpython3\.11.*'

这次 pkgfile 比 pacfiles 快了不少。使用正则搜索时,pacfiles 没有使用索引,也是遍历数据,所以快不起来了。

不过 pacfiles 是支持通配符搜索的,也能用上索引,很快的。pacman -F 不支持这个。而 pkgfile 嘛……它不仅慢,好像还又出 bug 了。

pkgfile -g '*libpython3.11*'

pacfiles '*libpython3.11*'

如果我写 pacfiles 之前得知 pkgfile 修了多架构那个 bug,我也许就不会写 pacfiles 了。不过现在对比下来,我也不后悔啦。

另外值得注意的是,pacfiles 无论是输出、还是命令行选项,都尽力兼容 pacman -F 的,以方便用户迁移。

幕后

其实我很早就想弄一个更快的 pacman -F 了。我首先想到的是,把数据塞进 SQLite3 里让它查。性能确实是好得不得了,但是一看生成的数据库,好几个 G……后来又尝试像 pacman -F 那样直接读压缩包,但是不一次性加载到内存,因此不需要那么多内存。但结果并不理想:解压和遍历搜索都不太能快得起来,最多并行处理多个数据库而已。plocate 是很快啦,但是它的数据结构是自己定制的,并不是库,不能直接拿来用。于是此事便放下了。

直到前不久,我读到《Succinct data structures》一文,特别是文中提到的 FM-index——这不正好能用来搜索文件名吗?不过,plocate 用的是什么数据结构来着?于是我去翻代码恢复了一下久远的记忆。哦,是 zstd 压缩的 trigram 倒排索引啊。好像也不错,还支持通配符呢。正则搜索它倒是没用上索引,因为作者认为「使用 locate 进行正则搜索太小众了」所以没有花精力去实现。

但是,以上关于数据结构的内容都不是重点!重点是,我发现了个 plocate-build 命令!它支持从纯文本创建 plocate 数据库!那我不是直接把文件名传给它就好了嘛~唯一有点遗憾的是,它不支持从管道读取文件名列表,因此需要先输出到临时文件中再给它使用,过程中会占用不少内存(/tmp 空间)。至于查询,调用 plocate 命令拿到结果再稍微处理一下就好了。于是想到就做,这就有了现在的 pacfiles(其实早期版本也在 git 历史里有)。

项目地址:https://github.com/lilydjwg/pacfiles。AUR 有 pacfiles-git 包。也可以 cargo install pacfiles 安装。

Category: Linux | Tags: Arch Linux Rust
1
11
2025
0

用 Android 手机当电脑的话筒

我之前是使用 ROC 来做这件事的。手机上安装 roc-droid,电脑上安装 pipewire-roc 然后执行 pactl load-module module-roc-source source_name=roc-source 就行。

但是这样会有一个问题:手机上的 roc-droid 会被休眠。换手机之前用的 Android 10 还好一点,可以设置半小时的「超长」关屏时间,并且屏幕关闭之后 roc-droid 还能活跃一段时间。现在换 Android 14 了,关屏之后 roc-droid 会立刻被休眠,也不能把 roc-droid 切到后台,否则录音会停止。为了让录音不中断,只能让手机「喝点咖啡因」来保持亮屏,于是不光网络和录音费电,屏幕也要费电。其实这个问题不是不能解决,放个持久通知就可以了,但是我不会 Android 开发呀。

ROC 方案另外的小问题有:网络会持续占用,即使没在使用。手机要么录音、要么播放,需要手工切换。roc-droid 时不时会崩溃。

后来从群友那里了解到可以在 termux 里跑 PulseAudio,我试了试,比 ROC 方案好用多啦。

手机上除了需要安装 termux 和 pulseaudio 外,还需要安装 Termux:API。为了方便启动,我还安装了 Termux:Widget。记得给 Termux:API 话筒权限。然后编辑 PulseAudio 配置文件 /data/data/com.termux/files/usr/etc/pulse/default.pa.d/my.pa:

load-module module-sles-source
load-module module-native-protocol-tcp auth-ip-acl=电脑的IP地址 auth-anonymous=true

这里的 sles 模块是用来录音的。

编辑 /data/data/com.termux/files/usr/etc/pulse/daemon.conf 文件,设置一小时不用才自动退出(默认20秒太短了):

exit-idle-time = 3600

然后在需要的时候执行 pulseaudio 命令就可以了。

电脑上的话,其实设置 PULSE_SERVER 环境变量就可以用上了。不过为了更好的集成,我们创建个 tunnel:

pactl load-module module-tunnel-source server=tcp:手机的IP地址

source 就是把手机当话筒用,改成 sink 的话则是把手机当音箱用了。

执行之后,在 PulseAudio / PipeWire 里就会多出来相应的 source(或者 sink)设备了。想怎么用就可以怎么用了~

但若是要同时使用另外的音箱来播放声音的话,手机话筒会把音箱播放的声音录进去,造成「回声」。这时候,就需要设置一下回声消除了。我参考了 ArchWiki,PipeWire 配置如下:

context.modules = [
    {   name = libpipewire-module-echo-cancel
        args = {
            monitor.mode = true
            source.props = {
                node.name = "source_ec"
                node.description = "Echo-cancelled source"
            }
        }
    }
]

然后去 pavucontrol 里设置一下它生成的两个录音操作的设备(一个是选话筒,另一个是选外放的音箱的 monitor 设备),并把消除了回声的 source 设备设置为默认音频输入设备就好了。

Category: Linux | Tags: Android 音频 Linux
12
11
2024
2

使用 ffmpeg 对音频文件进行响度归一化

我喜欢用本地文件听歌:没有广告、没有延迟、没有厂商锁定。但是有个问题:有的歌曲文件音量挺大的,比如 GARNiDELiA 和桃色幸运草Z的都感觉特别吵,需要调小音量,但有的音量又特别小,以至于我时常怀疑音频输出是不是出了问题。

这时候就要用到响度归一化了。响度衡量的是人的主观感知的音量大小,和声强——也就是声波的振幅大小——并不一样。ffmpeg 自带了一个 loudnorm 过滤器,用来按 EBU R128 标准对音频做响度归一化。于是调整好参数,用它对所有文件跑一遍就好了——我最初是这么想的,也是这么做的。

以下是我最初使用的脚本的最终改进版。是的,改进过好多次。小的改进如排除软链接、反复执行时不重做以前完成的工作;大的改进如使用 sem 并行化、把测量和调整两个步骤分开。之所以有两个步骤,是因为我要线性地调整响度——不要让同一个音频不同部分受到不同程度的调整。第一遍是测量出几个参数,这样第二遍才知道怎么调整。只过一遍的是动态调整,会导致调整程度不一,尤其是开头。

至于参数的选择,整体响度 I=-14 听说是 YouTube 它们用的,而真峰值 TP=0 和响度范围 LRA=50 是因为我不想给太多限制。

#!/bin/zsh -e

for f in **/*.{flac,m4a,mp3,ogg,opus,wma}(.); do
  json=$f:r.json
  if [[ -s $json || $f == *_loudnorm.* ]]; then
    continue
  fi
  echo "Processing $f"
  export f json
  sem -j+0 'ffmpeg -i $f -af loudnorm=print_format=json -f null /dev/null </dev/null |& sed -n ''/^{$/,/^}$/p'' > $json; echo "Done with $f"'
done

sem --wait

for f in **/*.{flac,m4a,mp3,ogg,opus,wma}(.); do
  json=$f:r.json
  output=$f:r_loudnorm.$f:e
  if [[ ! -f $json || -s $output || $f == *_loudnorm.* ]]; then
    continue
  fi
  echo "Processing $f"
  export f json output
  sem -j+0 'ffmpeg -loglevel error -i $f -af loudnorm=linear=true:I=-14:TP=0:LRA=50:measured_I=$(jq -r .input_i $json):measured_TP=$(jq -r .input_tp $json):measured_LRA=$(jq -r .input_lra $json):measured_thresh=$(jq -r .input_thresh $json) -vcodec copy $output </dev/null; echo "Done with $f"'
done

sem --wait

不得不说 zsh 的路径处理是真方便。相对地,sem 就没那么好用了。一开始我没加 </dev/null,结果 sem 起的进程全部 T 在那里不动,strace 还告诉我是 SIGTTOU 导致的——我一直是 -tostop 的啊,也没见着别的时候收到 SIGTTOU。后来尝试了重定向 stdin,才发现其实是 SIGTTIN——也不知道 ffmpeg 读终端干什么。另外,给 sem 的命令传数据也挺不方便的:直接嵌在命令里,空格啥的会出问题,最后只好用环境变量了。

等全部处理完毕,for f in **/*_loudnorm.*; do ll -tr $f:r:s/_loudnorm//.$f:e $f; done | vim - 看了一眼,然后就发现问题了:有的文件变大了好多,有的文件变小了好多!检查之后发现是编码参数变了:mp3 文件全部变成 128kbps 了,而 flac 的采样格式从 s16 变成了 s32。

于是又写了个脚本带上参数重新处理。这次考虑到以后我还需要对单个新加的歌曲文件处理,所以要处理的文件通过命令行传递。

#!/bin/zsh -e

doit () {
  local f=$1
  local json=$f:r.json
  local output=$f:r_loudnorm.$f:e

  echo "Processing $f"

  if [[ -s $json || $f == *_loudnorm.* ]]; then
  else
    ffmpeg -i $f -af loudnorm=print_format=json -f null /dev/null </dev/null |& sed -n '/^{$/,/^}$/p' > $json
  fi

  if [[ ! -f $json || -s $output || $f == *_loudnorm.* ]]; then
  else
    local args=()
    if [[ $f == *.mp3 || $f == *.m4a || $f == *.wma ]]; then
      local src_bitrate=$(ffprobe -v error -select_streams a:0 -show_entries stream=bit_rate -of json $f | jq -r '.streams[0].bit_rate')
      args=($args -b:a $src_bitrate)
    fi
    if [[ $f == *.m4a ]]; then
      local src_profile=$(ffprobe -v error -select_streams a:0 -show_entries stream=profile -of json $f | jq -r '.streams[0].profile')
      if [[ $src_profile == HE-AAC ]]; then
        args=($args -acodec libfdk_aac -profile:a aac_he)
      fi
    fi
    if [[ $f == *.opus ]]; then
      local src_bitrate=$(ffprobe -v error -select_streams a:0 -show_entries format=bit_rate -of json $f | jq -r '.format.bit_rate')
      args=($args -b:a $src_bitrate)
    fi
    if [[ $f == *.ogg ]]; then
      local src_bitrate=$(ffprobe -v error -select_streams a:0 -show_entries stream=bit_rate -of json $f | jq -r '.streams[0].bit_rate')
      if [[ $src_bitrate == null ]]; then
        src_bitrate=$(ffprobe -v error -select_streams a:0 -show_entries format=bit_rate -of json $f | jq -r '.format.bit_rate')
      fi
      args=($args -b:a $src_bitrate)
    fi
    if [[ $f == *.flac ]]; then
      local src_sample_fmt=$(ffprobe -v error -select_streams a:0 -show_entries stream=sample_fmt -of json $f | jq -r '.streams[0].sample_fmt')
      args=($args -sample_fmt:a $src_sample_fmt)
    fi
    ffmpeg -loglevel error -i $f -af loudnorm=linear=true:I=-14:TP=0:LRA=50:measured_I=$(jq -r .input_i $json):measured_TP=$(jq -r .input_tp $json):measured_LRA=$(jq -r .input_lra $json):measured_thresh=$(jq -r .input_thresh $json) $args -vcodec copy $output </dev/null
    touch -r $f $output
  fi

}

for f in "$@"; do
  doit $f
done

然后我就神奇地发现,sem 不好用的问题突然没有了——我直接 parallel loudnorm ::: 文件们 就好了嘛……

Category: Linux | Tags: linux 音频
10
24
2024
1

为团队部署邮件服务

给服务器上的程序部署邮件服务十分简单,装个 Postfix 就搞定了。然而给人用的话就远远不够了。之所以要干这事,主要原因是之前使用的 Yandex 邮箱老出问题,丢邮件都算小事了,它还不让我登录 Web 界面,非要我填写我从未设置的密保问题的答案……

准备工作

要部署邮件服务,首先当然要有域名和服务器了。需要注意的是,最好使用可以设置 PTR 记录的服务器,有些邮件服务器会要求这个。

邮件传输代理

这是最重要的部分。邮件传输代理,简称 MTA,是监听 TCP 25 端口、与其它邮件服务器交互的服务程序。我最常用的是 Postfix,给服务器上的程序用的话,它相当简单易用。但是要给它配置上 IMAP 和 SMTP 登录服务、以便给人类使用的话,就很麻烦。好在之前听群友说过 maddy,不仅能收发邮件,还支持简单的 IMAP 服务。唯一的缺点是不支持通过 25 端口发送邮件——需要走 465 或者 587 端口,登录之后才能发件。它的账号系统也是独立于 UNIX 账号的,给程序使用需要额外的配置。

具体配置方面,首先是域名和 TLS 证书。我不知道为什么,它在分域名证书的选择上有些问题,最后我干脆全部用通配符证书解决了事。数据库我使用的是 PostgreSQL。要使用本地 peer 鉴权的话,需要把 host 的值设置为 PostgreSQL 监听套接字所在的目录,比如我是这样写的:

dsn "user=maddy host=/run/postgresql dbname=maddy sslmode=disable"

PostgreSQL 监听套接字所在目录是编译时确定的。maddy 是 Go 写的,并不使用 libpq,因此它无法自动确定这个目录在哪里,需要手动指定。

关于邮箱别名,可以使用文本文件配置,也可以使用数据库查询指定。别名功能可以用来实现简单的邮件列表功能——发往某一个地址的邮件会被分发到多个实际收件人的邮箱中。但是它不支持去重,也就是说,往包含自己的别名地址发送邮件,自己会额外收到一份。设置起来大概是这样子的:

table.chain local_rewrites {
    optional_step regexp "(.+)\+(.+)@(.+)" "$1@$3"
    optional_step static {
        entry postmaster postmaster@$(primary_domain)
    }
    optional_step file /etc/maddy/aliases
    step sql_query {
        driver postgres
        dsn "user=maddy host=/run/postgresql dbname=maddy sslmode=disable"                                                                                                                 
        lookup "SELECT mailname FROM mailusers.mailinfo WHERE $1 = ANY(alias) and new = false"
    }
}

哦对了,那个 postmaster 地址需要手动合并,不然就要每个域名创建一个账号了。在别名文件里写上 postmaster@host2: postmaster@host1 就行了。

maddy 会经常检查别名的修改时间然后自动重新加载,数据库查询当然是查出来是什么就是什么,所以还是比 Postfix 每次跑 postalias 命令要方便不少。

DNS 配置

邮件域名的 MX 记录当然要设置上的。邮件服务器 IP 的 PTR 记录也要设置到服务器的域名上(A / AAAA 记录指到服务器)。SPF 的记录也不能忘。DMARC 和 DKIM 的记录没那么重要,不过推荐按 maddy 的文档设置上。

我还给域名设置 imap、imaps 和 submission 的 SRV 记录,但似乎客户端们并不使用它们。

这些设置好之后就可以去 https://email-security-scans.org/ 发测试邮件啦。

反垃圾

maddy 内建对 rspamd 的支持,所以就用它好了。直接在 smtpcheck 节里写上 rspamd 就好了。rspamd 跟着官方教程走,也基本不需要什么特别的设置,就是官方给的 nginx 配置有些坑人。我是这样设置的:

    location /rspamd/ {
            alias /usr/share/rspamd/www/;
            expires 30d;
            index index.html;
            try_files $uri $uri/ @proxy;
    }
    location @proxy {
            rewrite ^/rspamd/(.*)$ /$1 break;
            proxy_pass  http://127.0.0.1:11334;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header Host $http_host;
    }

注意这里给静态文件设置了过期时间,不然每次访问都要下载那些文件,非常慢。我是挂载在子路径下的,需要通过 rewrite 配置把子路径给删掉再传给 rspamd,不然会出问题。

邮件客户端自动配置

上边提到了 SRV 记录并不管用。实际上管用是在 https://autoconfig.example.org/mail/config-v1.1.xml 的配置文件。具体可以看 Lan Tian 的《编写配置文件,让 Thunderbird 自动配置域名邮箱》这篇文章。

Web 邮件客户端

使用的是 Roundcube,是一个 PHP 软件。可以跟着 ArchWiki 的教程配置。注意最好别跟着配置 open_basedir,因为会影响同一 php-fpm 实例上的其它服务。另外记得配过期时间,不然每次都要下载静态资源,很慢的。

因为上边部署了 rspamd 反垃圾服务,所以也可以给 Roundcube 启用一下 markasjunk 插件,并在 /usr/share/webapps/roundcubemail/plugins/markasjunk/config.inc.php 配置一下对应的命令:

$config['markasjunk_spam_cmd'] = 'rspamc learn_spam -u %u -P PASSWORD %f';
$config['markasjunk_ham_cmd'] = 'rspamc learn_ham -u %u -P PASSWORD %f';

不过我配置这个之后,命令会按预期被调用,但是 rspamd 的统计数据里不知为何总显示「0 Learned」。把垃圾邮件通过命令行手动喂给它又会提示已经学过该邮件了。

Category: Linux | Tags: linux mail

Mastodon | Theme: Aeros 2.0 by TheBuckmaker.com