通过字幕总结YouTube视频内容

<p style="text-indent: 2em; font-style: italic;">本文来自<a href="https://blog.lilydjwg.me/">依云's Blog</a>,转载请注明。</p><p> 现在YouTube首页的视频推荐越来越差了,好多标题党、clickbait,故意把讨论的主题藏起来。经常我点进去看了好久,才发现原来并没有讨论什么我之前不知道信息,又或者讨论的话题我完全不感兴趣。再不就是把我想知道的信息藏到不知道哪个片段里。虽然我有<a href="https://github.com/polywock/globalSpeed">GlobalSpeed</a>扩展可以依内容信息密度来方便地调整播放速度,但是调太快(超过2x)就听不清啦。总之是好多视频点开看之前十分吸引人,但看完或者看一半时就想骂人、点踩退出,十分浪费时间。</p> <p> 正好最近在尝试Gemini API,于是灵光一现,写了个脚本,使用<a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a>下载视频字幕,然后调用Gemini API来总结内容。</p> <pre class="brush: python;" title="yt-summarize"> #!/usr/bin/python3 import sys import json import subprocess import tempfile from pathlib import Path import httpx GEMINI_KEY = &#39;YOUR GEMINI KEY HERE&#39; URL = &#39;https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite:streamGenerateContent?alt=sse&#39; PROMPT = &#39;根据以下字幕文本总结视频内容。总结结果中请不要包含任何赞助商推广信息。&#39; def main(url, sublang): with tempfile.TemporaryDirectory() as d: subprocess.run([ &#39;yt-dlp&#39;, &#39;--sub-langs&#39;, sublang, &#39;--write-subs&#39;, &#39;--write-auto-subs&#39;, &#39;--skip-download&#39;, url, ], cwd=d, check=True, ) p = Path(d) try: file = tuple(p.iterdir())[0] except IndexError: sys.exit(&#39;No subtitles.&#39;) for _ in range(2): try: do_request(file) break except httpx.ReadError as e: print(e, file=sys.stderr) def do_request(filepath): client = httpx.Client(http2=True) filename = filepath.name with filepath.open() as f: subtitles = f.read() parts = [{ &#39;text&#39;: PROMPT, }, { &#39;text&#39;: f&#39;文件名:{filename}\n文件内容:\n{subtitles}&#39;, }] j = { &#39;contents&#39;: [{ &#39;parts&#39;: parts }], } with client.stream( &#39;POST&#39;, URL, headers = { &quot;X-goog-api-key&quot;: GEMINI_KEY, &quot;Content-Type&quot;: &quot;application/json&quot;, }, json=j, timeout=120, ) as r: for line in r.iter_lines(): if not line.startswith(&#39;data: &#39;): continue line = line.removeprefix(&#39;data: &#39;) data = json.loads(line) for a in data[&#39;candidates&#39;]: for b in a[&#39;content&#39;][&#39;parts&#39;]: text = b[&#39;text&#39;] if not text: break print(text, end=&#39;&#39;, flush=True) print() if __name__ == &#39;__main__&#39;: import argparse parser = argparse.ArgumentParser() parser.add_argument(&#39;URL&#39;, help=&#39;YouTube URL&#39;) parser.add_argument(&#39;--lang&#39;, default=&#39;en&#39;, help=&#39;choose subtitles language&#39;) args = parser.parse_args() main(args.URL, args.lang) </pre> <p> 脚本依赖Python和httpx库。当然鉴于httpx已经不再更新,你换成<a href="https://github.com/pydantic/httpx2">httpx2</a>应该也能用。Gemini Key可以去<a href="https://aistudio.google.com/app/projects">这里</a>生成,然后填到脚本开头。我使用的是gemini-3.1-flash-lite这个模型,因为免费版本中,它的每日请求数配额比较充足。</p> <p> 当然啦,视频要有CC字幕这个脚本才能用,否则会报错。默认使用英文字幕,包含自动生成的版本。如果是中文视频,可以使用<code>--lang zh.*</code>参数指定用中文字幕。</p> <hr /> <p> <strong>2026年07月01日更新:</strong>脚本后续有些改进,包括支持传递yt-dlp参数、支持使用参数指定模型、支持本地模型、支持后续对话等。脚本放GitHub上了:<a href="https://github.com/lilydjwg/winterpy/blob/master/pyexe/yt-summarize">yt-summarize</a>。</p>

2026/6/9
阅读更多

自定义系统默认中文字体

<p style="text-indent: 2em; font-style: italic;">本文来自<a href="https://blog.lilydjwg.me/">依云's Blog</a>,转载请注明。</p><p> 一开始使用Linux系统的时候,并没有多少自由开源的中文字体。那时候几乎所有人的选择都是文泉驿正黑。我就一直用啊用了好多年,直到后来截图时被网友说该换字体了,我才知道原来文泉驿项目已经停止很久了,<a href="http://wenq.org/wqy2/index.cgi?action=browse&amp;id=Home&amp;lang=en">网站</a>上的新闻截止于2008年&mdash;&mdash;都快20年啦。</p> <p> 文泉驿正黑的字形比较「旧时代」&mdash;&mdash;以屏幕清晰度为优先,由于当时的屏幕普遍dpi低,笔画迁就像素风格,所以比较丑。另外也有些bug,比如「撨䑾詺㘃㞈㟯㫥」这几个字会有一片漆黑的区域。</p> <p> 文泉驿正黑看起来是这样的。注意截图中只有除标题外的中文部分用的是文泉驿正黑字体。</p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/default-zhcn-font/wqy-zenhei.png"><img alt="文泉驿正黑渲染的网页" src="https://store.lilydjwg.me/img/blog/default-zhcn-font/wqy-zenhei.png" /></a></p> <p> 2014年,Google联合Adobe发布了思源黑体和Noto Sans CJK字体&mdash;&mdash;这两款字体的汉字部分是相同的,区别只在于思源黑体会根据文本的区域设置来自动选择字形(通常不管使用哪个语言的字族名来指定),而Noto Sans CJK字族名只有英文版本,并且不同的字族名后缀会选择不同的地区字形。</p> <p> 我在2024年终于决定切换到思源黑体试试。它长这样:</p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/default-zhcn-font/sourcehansans.png"><img alt="思源黑体渲染的网页" src="https://store.lilydjwg.me/img/blog/default-zhcn-font/sourcehansans.png" /></a></p> <p> 图中的日文部分也是使用的思源黑体,只不过是日文字形。标题则使用的是思源宋体。读者可以在图片上点「右键」然后新建标签页打开图像,然后来回切换着对比。</p> <p> 可以看出,思源黑体是比文泉驿正黑好看多啦。而且思源字体有粗体版本,文泉驿正黑是没有粗体的,只有合成出来的所谓「伪粗体」。</p> <p> 但是,你有没有发现有什么地方不对劲?注意看大标题下方那行字,「条目」和「阅读」下边的装饰线与该行下方的分隔线有一段距离,而「大陆简体」和「工具」两处字偏低。事实上,思源黑体的问题远不止这些。思源黑体文字上方空出来的空间比下方多不少,造成行高太高、终端里文字不居中等各种问题。</p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/default-zhcn-font/layout-sourcehansans.png"><img alt="思源黑体的布局" src="https://store.lilydjwg.me/img/blog/default-zhcn-font/layout-sourcehansans.png" /></a></p> <p> (这个图片是使用命令<code>pango-view --dpi=1024 --font=思源黑体 --annotate=glyph,layout,baselines -t A测试中文Test -o out.png</code>生成的。)</p> <p> 因此我用了几天就换回文泉驿正黑了,但是把思源黑体作为部分网页字体在用(主要用于非简体中文内容,以及通过<a href="https://addons.mozilla.org/firefox/addon/styl-us/">stylus</a>指定的特定几个网站)。</p> <p> 最近,oldherl说<a href="https://github.com/be5invis/Sarasa-Gothic">更纱黑体</a>修了行高的问题。于是我又试了几天这个基于思源黑体的字体。它的效果是这样的:</p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/default-zhcn-font/sarasa.png"><img alt="更纱黑体渲染的网页" src="https://store.lilydjwg.me/img/blog/default-zhcn-font/sarasa.png" /></a></p> <p> 行高的问题确实解决了!但是&mdash;&mdash;又要「但是」了&mdash;&mdash;它的字怎么矮胖矮胖的?是我没看习惯的原因吗?于是我使用了几天,最后并没有习惯,反而是和思源黑体一对照,发现确实是许多字都变矮了一点。</p> <p> 于是我只好又换回已经用习惯了的文泉驿正黑。</p> <p> 诶等等!既然更纱黑体能修行高,我为什么不行?因为我不会,可是,今非昔比了呀&mdash;&mdash;Gemini,给我来个脚本!</p> <p> 于是就有了这么个脚本&mdash;&mdash;当然这个脚本是我改过的最终版本了。</p> <pre class="brush: python;" title="LilyHanSans"> #!/usr/bin/python3 from io import BytesIO from fontTools.ttLib import TTCollection, TTFont def adjust_font(font): &nbsp; &nbsp; target_ascent = 1025 &nbsp; &nbsp; target_descent = -265 &nbsp; &nbsp;&nbsp; &nbsp; &nbsp; # 修改 hhea 表 (macOS/Pango 渲染常用) &nbsp; &nbsp; font[&#39;hhea&#39;].ascent = target_ascent &nbsp; &nbsp; font[&#39;hhea&#39;].descent = target_descent &nbsp; &nbsp; font[&#39;hhea&#39;].lineGap = 92 &nbsp; &nbsp; # 修改 OS/2 表 (Windows/Linux 合规性) &nbsp; &nbsp; font[&#39;OS/2&#39;].sTypoAscender = target_ascent &nbsp; &nbsp; font[&#39;OS/2&#39;].sTypoDescender = target_descent &nbsp; &nbsp; font[&#39;OS/2&#39;].sTypoLineGap = 92 &nbsp; &nbsp; # usWin 参数决定了红线(剪切区域),设为相同值可消除额外间距 &nbsp; &nbsp; font[&#39;OS/2&#39;].usWinAscent = target_ascent &nbsp; &nbsp; font[&#39;OS/2&#39;].usWinDescent = abs(target_descent) &nbsp; &nbsp; # 2. 修改 Font Family 名称 &nbsp; &nbsp; name_table = font[&#39;name&#39;] &nbsp; &nbsp; for record in name_table.names: &nbsp; &nbsp; &nbsp; &nbsp; name_str = record.toUnicode() &nbsp; &nbsp; &nbsp; &nbsp; new_record_str = name_str.replace(&#39;Source Han&#39;, &#39;Lily Han&#39;) \ &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; .replace(&#39;思源&#39;, &#39;百合&#39;) \ &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; .replace(&#39;源ノ角ゴシック&#39;, &#39;百合ノ角ゴシック&#39;) \ &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; .replace(&#39;본고딕&#39;, &#39;백합고딕&#39;) \ &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; .replace(&#39;SourceHan&#39;, &#39;LilyHan&#39;) &nbsp; &nbsp; &nbsp; &nbsp; # 针对不同 ID 进行替换 &nbsp; &nbsp; &nbsp; &nbsp; # ID 1: Family Name, ID 4: Full Name, ID 6: PostScript Name 等 &nbsp; &nbsp; &nbsp; &nbsp; if name_str != new_record_str: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; name_table.setName(new_record_str, record.nameID, record.platformID, record.platEncID, record.langID) &nbsp; &nbsp; # Medium as Semibold &nbsp; &nbsp; if font[&#39;OS/2&#39;].usWeightClass == 500: &nbsp; &nbsp; &nbsp; &nbsp; # save then read to copy the font without referencing existing data structures &nbsp; &nbsp; &nbsp; &nbsp; buf = BytesIO() &nbsp; &nbsp; &nbsp; &nbsp; font.save(buf) &nbsp; &nbsp; &nbsp; &nbsp; buf.seek(0) &nbsp; &nbsp; &nbsp; &nbsp; sb_font = TTFont(buf) &nbsp; &nbsp; &nbsp; &nbsp; sb_font[&#39;OS/2&#39;].usWeightClass = 600 &nbsp; &nbsp; &nbsp; &nbsp; for record in sb_font[&#39;name&#39;].names: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; name_str = record.toUnicode() &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if &quot;Medium&quot; in name_str: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; new_name = name_str.replace(&quot;Medium&quot;, &quot;Semibold&quot;) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; record.string = new_name.encode(record.getEncoding()) &nbsp; &nbsp; &nbsp; &nbsp; cff = sb_font[&#39;CFF &#39;].cff &nbsp; &nbsp; &nbsp; &nbsp; for i in range(len(cff.fontNames)): &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if &quot;Medium&quot; in cff.fontNames[i]: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; cff.fontNames[i] = cff.fontNames[i].replace(&quot;Medium&quot;, &quot;Semibold&quot;) &nbsp; &nbsp; &nbsp; &nbsp;&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; # 修改 TopDict 内部的名称 &nbsp; &nbsp; &nbsp; &nbsp; for topDict in cff.topDictIndex: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; for attr in [&#39;FullName&#39;, &#39;FamilyName&#39;, &#39;Weight&#39;]: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if val := getattr(topDict, attr): &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if isinstance(val, str) and &quot;Medium&quot; in val: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; setattr(topDict, attr, val.replace(&quot;Medium&quot;, &quot;Semibold&quot;)) &nbsp; &nbsp; &nbsp; &nbsp; return sb_font def main(input_ttc, output_ttc): &nbsp; &nbsp; ttc = TTCollection(input_ttc) &nbsp; &nbsp; new_fonts = list(ttc.fonts) &nbsp; &nbsp; for font in ttc.fonts: &nbsp; &nbsp; &nbsp; &nbsp; newfont = adjust_font(font) &nbsp; &nbsp; &nbsp; &nbsp; if newfont: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; new_fonts.append(newfont) &nbsp; &nbsp; ttc.fonts = new_fonts &nbsp; &nbsp; ttc.save(output_ttc) &nbsp; &nbsp; print(f&quot;成功保存至: {output_ttc}&quot;) if __name__ == &#39;__main__&#39;: &nbsp; &nbsp; import nicelogger &nbsp; &nbsp; nicelogger.enable_pretty_logging(&#39;DEBUG&#39;) &nbsp; &nbsp; input_ttc = &quot;SourceHanSans.ttc&quot; &nbsp; &nbsp; output_ttc = &quot;LilyHanSans.otc&quot; &nbsp; &nbsp; main(input_ttc, output_ttc)</pre> <p> 脚本使用fonttools这个Python库,把ascent、descent和lineGap这三个参数改成和文泉驿正黑一样的了。之所以要照着文泉驿正黑来改,是因为我的终端最大化之后,使用13pt字号差不多刚好填满35行(只空出来两行像素)。而使用别的参数,我调整字号好久,都会空出来小半到大半甚至接近一行文字的高度。</p> <p> 另外,这个脚本运行起来非常耗资源:它保存的时候会重新计算每一个字形,持续占用一个CPU核心长达六分多钟,内存只分配不释放,最终用掉接近20GiB。倒是生成的文件差异不大,用rsync很快就能同步回来。</p> <p> 最终效果图:</p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/default-zhcn-font/lilyhansans.png"><img alt="百合黑体渲染的网页" src="https://store.lilydjwg.me/img/blog/default-zhcn-font/lilyhansans.png" /></a></p> <p> 可惜的是思源黑体只有粗体和Medium,不像更纱黑体那样有半粗(Semibold)版本。思源黑体的粗体挺粗的。</p> <p> 由于我在使用思源黑体时遇到的问题都是在UI部分,而这里又不需要用到宋体,所以我没有去改思源宋体,其它地区的字形也还是用的原本的思源系列。</p> <p> 如果有人想试试我生成的这个字体的话,在<a href="https://store.lilydjwg.me/files/fonts/LilyHanSans.otc">这里下载</a>(112 MiB)。</p> <hr /> <p> <strong>2026年05月13日更新:</strong>更新了脚本和字体文件。现在把Medium复制一份然后改叫Semibold了,因此有了半粗字重,在用到的地方会更接近设计者的意图,也看着更舒服。</p>

2026/5/11
阅读更多

Wayfire支持不缩放Xwayland啦

<p style="text-indent: 2em; font-style: italic;">本文来自<a href="https://blog.lilydjwg.me/">依云's Blog</a>,转载请注明。</p><p> Wayland协议会告诉客户端程序缩放比率应该是多少,支持的就按指定的来,不支持的Wayland compositor就负责把窗口缩放到合适的大小。但因为是客户端渲染成位图之后,再由Wayland compositor缩放,大小是合适了,但却模糊了起来。然而很不幸地,Xwayland就是那个不支持Wayland缩放机制所以被Wayland compositor强行缩放的Wayland客户端。</p> <p> 但其实在X的时代,各个GUI框架都发展出来了自己的一套指定缩放的机制。比如一些老的程序会认<code>Xft.dpi</code>这个设置,GTK可以用<code>GDK_SCALE</code>环境变量,Qt认<code>QT_AUTO_SCREEN_SCALE_FACTOR</code>和<code>QT_SCREEN_SCALE_FACTORS</code>,Wine可以在winecfg里设置,等等。我想说的是,虽然不太统一,但大家其实都有自己的HiDPI方案了。</p> <p> 所以,Wayland compositor如果不强行缩放Xwayland的窗口的话,那些还不原生支持Wayland的程序其实也能显示得清晰好看的。但可惜的是Xwayland始终没有合并任何解决方案,我也就只好用着打过补丁的<code>wlroots-lily-git</code>、<code>xorg-xwayland-lily</code>了。KDE很早就引入不缩放Xwayland窗口的选项、让这些使用Xwayland的程序自己处理缩放。Hyprland后来也支持了。而现在,终于轮到<a href="https://github.com/WayfireWM/wayfire/commit/e3f7f32b8a69a66b9931c1acb5881433b29eb976">Wayfire支持</a>啦~</p> <p> Wayfire的这个选项叫<code>workarounds/force_xwayland_scaling</code>。设置为<code>true</code>就是程序自己处理缩放、Wayfire不管它了。一开始没处理鼠标光标,后来也修好了。xorg-xwayland-lily终于完成了它的使命~</p> <p> 顺便说一下,<code>workarounds</code>这里我还设置了另外两个选项。一个是<code>discard_command_output=false</code>,这样能看到Wayfire启动的程序的报错信息。另一个是<code>auto_reload_config=false</code>,禁用自动重新加载配置文件,不用和git打架了。</p> <pre class="brush: plain;"> [workarounds] discard_command_output = false auto_reload_config = false force_xwayland_scaling = true </pre>

2026/4/18
阅读更多

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

<p style="text-indent: 2em; font-style: italic;">本文来自<a href="https://blog.lilydjwg.me/">依云's Blog</a>,转载请注明。</p><p> 显示器送去维修了,因此我的台式机变成无头设备啦。现在用它来编译软件是没啥问题的,但是我的GUI软件的窗口全部访问不了啦,编译出来的wayfire也没法调试了。本来已经存在的窗口我打算等显示器修好回来再用的,但是Vim刚出了一个公开利用方式的<a href="https://github.com/vim/vim/security/advisories/GHSA-2gmj-rpqf-pxvh">RCE</a>,我怕我的GVim留在那里忘记了然后不小心中招,直接关又不记得它有没有打开什么需要处理的文件。于是想到了远程桌面。</p> <p> 首先想到的是群友的<a href="https://github.com/AlynxZhou/reframe">reframe</a>。之前显示器还在的时候它挺好用的,但是现在显示器不在了,它就连不上去啦。读了文档才知道需要配置一个虚拟显示器,要动内核参数所以需要重启,我的窗口们就回不来啦。另外等显示器回来,要去掉虚拟显示器还要再次重启。</p> <p> 其实我还有另一个显示器,也就是<a href="https://blog.lilydjwg.me/2018/2/12/e-ink-paperlike-hd.212155.html">大上的E-ink屏幕</a>。接上去之后,我的wayfire桌面回来了,但是我的窗口并没有&hellip;&hellip;<a href="https://github.com/lilydjwg/taskmaid/blob/master/scripts/lswin">lswin</a>发现它们还在「unknown」显示器上。于是我开始研究<a href="https://github.com/WayfireWM/pywayfire">Wayfire IPC</a>看看怎么把它们弄回来。</p> <p> 花了些时候,去Matrix频道里问了一下,窗口们是用<code>configure_view</code>找回来了,但是发现我怎么几百个叫「input-method-popup」的窗口啊(其实这就是我要调试Wayfire的原因)。另外发现有创建无头输出的接口,也就是Wayland compositor层级的虚拟显示器啦。</p> <p> 墨水屏用来阅读和写代码挺好的,但是用来测试GUI软件就不太适合了,更不用提打游戏了。所以就想着既然能创建无头输出了,那我是不是可以把无头输出VNC出来、在笔记本屏幕上看?于是读了一下wayvnc的文档,发现还真可以。</p> <p> 创建无头输出:</p> <pre class="brush: python;"> import wayfire, os addr = os.environ.get(&#39;WAYFIRE_SOCKET&#39;, os.environ[&#39;XDG_RUNTIME_DIR&#39;] + &#39;/wayfire-wayland-1-.socket&#39;) sock = wayfire.WayfireSocket(addr) sock.create_headless_output(1920, 1080) </pre> <p> 然后记下创建出来的输出的名字(<code>HEADLESS-</code>开头的那个)。几经尝试,最终使用的wayvnc命令是:</p> <pre class="brush: plain;" title="sh"> wayvnc -f 60 -r -g -o HEADLESS-2 IP PORT </pre> <p> 其中<code>-f 60</code>指定帧率,默认是30fps;<code>-r</code>指定渲染光标,否则在服务端使用鼠标时不显示光标;<code>-g</code>启用需要GPU的特性;<code>-o HEADLESS-2</code>是选择之前创建的无头输出。后边的监听地址我指定了与笔记本直连的有线网口的IP,这样不需要设置密码、也不用加密就很安全。</p> <p> 然后使用tigervnc连接:</p> <pre class="brush: bash;"> LANGUAGE=en_US vncviewer -Maximize IP:PORT -PreferredEncoding H.264 </pre> <p> vncviewer显示中文有问题,因此使用英文界面。这里指定了编码方式为H.264,希望能比默认更好一些。我原本还想用Raw来着,但是问了一下Gemini,说是1Gbps不够1080p60用,另外序列化延迟也会更高。不过wayvnc和vncviewer都没有使用VAAPI的样子,不知道是不支持还是哪里有问题。</p> <p> 实际占用的带宽最高为27 MiB/s。延迟挺低的,反正我感觉很不明显,玩游戏也没有问题。不过可能是因为有压缩,文字的显示不是很清晰。</p> <p> 哦对了,因为vncviewer是Xwayland软件,因此它自己的键盘捕获是没有用的。我使用Wayfire的shortcuts-inhibit设置让它默认捕获键盘了,需要的时候再按快捷键临时取消一下。</p> <pre class="brush: plain;"> [shortcuts-inhibit] break_grab = &lt;ctrl&gt; &lt;alt&gt; &lt;shift&gt; KEY_ESC inhibit_by_default = app_id is &quot;Vncviewer&quot; </pre> <p> 至于为什么不用别的VNC客户端,gtk-vnc慢死了,remmina界面好复杂,virt-viewer我不知道怎么叫它连远程VNC。</p>

2026/4/2
阅读更多

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

<p style="text-indent: 2em; font-style: italic;">本文来自<a href="https://blog.lilydjwg.me/">依云's Blog</a>,转载请注明。</p><p> 偶然间发现Discourse论坛支持利用文本嵌入模型来生成「相关话题」列表、提供语义化搜索。于是我给<a href="https://forum.archlinuxcn.org/">Arch Linux中文论坛</a>试过了好几个模型,记录一下经验。</p> <p> 文本嵌入,英文叫「text embedding」,指的是将一段文本编码成语义空间中的向量,从而可以判断不同文本的语义相关性。编码出来的向量少则512维,多的能有4096维。而判断相关性有「余弦距离」(看两个向量的夹角大小)和「负内积」(一个向量和另一个向量的转置相乘,然后取负)两种方法,我都是看模型文档和示例来决定用哪个的。至于这些向量的存储和索引,Discourse使用的是<a href="https://github.com/pgvector/pgvector">pgvector</a>这个PostgreSQL插件。</p> <p> Discourse启用这个功能之后,会在每个话题下方推荐几个「相关话题」,很适合看看是不是有人问过相同的问题。语义化搜索则需要在搜索页面点按钮来显示。在搜索框里按两下回车,就能到搜索页面了(这时候语义化搜索就会进行了,虽然用户还看不到结果),或者点搜索框右边的按钮也行。</p> <p> 因为论坛以中文为主,所以没多少可以抄Discourse官方文档的地方。一开始我挑了好几个来尝试,bge-m3、all-mpnet-base-v2、gte-multilingual-base等。但是没想到它们体积不大,但跑起来却很吃资源。E5-2678 v3辛辛苦苦跑了好久,结果去数据库里一看,已索引的话题数量才几个、十几个,而且不见涨&hellip;&hellip;后来写了API转换代理我才知道,原来是因为Discourse会批量并发请求,并发度会高达45左右,于是很容易导致本来就慢的请求因为排队太久而超时被放弃,CPU都白算了。</p> <p> 最终我找到gte-base-zh这个模型,是针对中文特化的。很小,才0.1B,但这CPU跑得动它。效果也还能接受。</p> <p> 后来了解到最近新出的<a href="https://huggingface.co/Qwen/Qwen3-Embedding-8B">Qwen3-Embedding</a>系列,看评分效果是最好的。又有群友愿意提供显卡算力,于是试了试。</p> <p> Qwen3-Embedding提供8B、4B、0.6B三种参数规模的模型。8B很重,我的6650XT的8G显存勉强能放下它的Q4_K_M量化版本。0.6B的只有Q8_0的量化版本,我的显卡跑起来轻松不少,就是不知道为什么它占了我4G+的显存,导致剩下的显存不够原神用了。另外运行的时候如果不用systemd的<code>CPUWeight</code>之类的手段降一下CPU优先级,会导致我的桌面也很卡&mdash;&mdash;我没找到调整GPU优先级的方法,不过调整CPU优先级也管用。</p> <p> 这些模型在群友提供的RYZEN AI MAX+ 395上跑得就比较惨。这台设备有算力不错的核显&mdash;&mdash;至少比用Linux的Apple M2 Ultra算得更快一些,也有核显能够使用大量内存的优势,但是!amdgpu驱动会在高负载时崩溃重置!这么久过去了,amdgpu依旧不待见核显啊(不过听说Intel那边新的xe驱动也有不少bug)。不过断断续续跑了几天之后,终于把大部分话题都索引好了。</p> <p> 后来我还是换0.6B模型了,因为群友提供的算力并不稳定,我想要更容易替代的方案。可能Qwen3-Embedding系列模型对我的用途来说实在是太优秀了,以至于不管是0.6B还是8B,我都没发现结果有什么明显的差异。但0.6B对性能的需求低很多,甚至编译机上的7950X3D也能跑&mdash;&mdash;虽然编译机没那么多时间能跑它就是了。</p> <p> 我还尝试过Google家的embeddinggemma-300M模型。它的<a href="https://huggingface.co/spaces/mteb/leaderboard">MTEB评分</a>比gte-base-zh要高,但只比gte-base-zh大一倍。但实际用下来,呃,效果差很多,基本上没啥用,可能分数都得在别的语言上了吧。遂放弃。</p> <p> 目前的论坛文本嵌入算力主要由群友的RYZEN AI MAX+ 395提供。在它不在线的时候,则由另一位群友提供的Apple M2 Ultra编译机兼职。哪天要是它也有事不在了,还能由x86编译机接棒。在历史话题索引完毕之后,平时的请求其实挺少的。</p> <p> 哦对了,最近还接触过一个叫all-MiniLM-L6-v2的模型,超级小,只有22.7M参数,是火狐新加的地址栏语义化搜索用的。但是它只支持英文,对于中文来说纯粹在增加噪音,可以在about:config里搜索<code>places.semanticHistory.featureGate</code>关闭之。</p> <p> 最后说说运行这些模型的方式。对于给sentence-transformers用的模型,可以用<code>ghcr.io/huggingface/text-embeddings-inference:cpu-latest</code>这个容器来运行。缺点是,它只有支持CPU和CUDA的版本。所以我更喜欢找gguf格式的模型,然后用llama.cpp来运行,可以使用Vulkan或者ROCm。不过我测试发现llama.cpp用ROCm还不如用Vulkan的来得快,而ROCm有着极其巨大的依赖库群,我就不用它了。要是乐意用ROCm的话,也可以用ollama来跑,支持动态加载和卸载模型&mdash;&mdash;但这对于长期运行的服务型用途来说并不是很适合,我还得传个参数让它不要一直加载卸载。</p>

2025/11/11
阅读更多

使用 Restic 备份数据

<p style="text-indent: 2em; font-style: italic;">本文来自<a href="https://blog.lilydjwg.me/">依云's Blog</a>,转载请注明。</p><p> 我很早就知道 <a href="https://restic.net/">Restic</a> 这个备份工具了,但是因为我有 <a href="https://blog.lilydjwg.me/2013/12/29/rsync-btrfs-dm-crypt-full-backup.42219.html">rsync</a> 和 btrfs send/receive 方案所以一直没用过。某天,突然有个走 AWS s3 协议的服务器备份需求,这才把它翻了出来。</p> <p> 既然是 s3,首先设置环境变量 <code>AWS_ACCESS_KEY_ID</code> 和 <code>AWS_SECRET_ACCESS_KEY</code>,然后仓库的地址是 <code>s3:域名/存储桶名</code>。好像一个存储桶里只能初始化一个 Restic 仓库?我还不清楚,也没有能给我玩的环境,就不管了。仓库地址可以设置到环境变量 <code>RESTIC_REPOSITORY</code> 中,这样就不用每次用 <code>-r</code> 参数指定了。执行 <code>restic init</code> 进行初始化。Restic 只支持加密备份,所以必须设置一个备份密码&mdash;&mdash;倒也可以设置为空字符串,但是加密仍旧会进行,并且需要额外的参数来允许空密码。密码也可以放在 <code>RESTIC_PASSWORD</code> 环境变量里。</p> <p> 然后就是执行备份命令了,很简单的,比如:</p> <pre class="brush: bash;"> restic backup -x --exclude-caches --exclude=&#39;/home/*/.cache/&#39; --exclude=/var/cache/pacman/pkg/&#39;*&#39; ... / </pre> <p> 这样就备份了整个系统,并排除了一些目录。可以先加 <code>-v --dry-run</code> 参数测试一遍。确定好备份的参数之后,就可以设定计划、反复执行了:</p> <pre class="brush: plain;" title="sysbackup.service"> [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 ... / </pre> <pre class="brush: plain;" title="sysbackup.timer"> [Unit] Description=backup system daily [Timer] OnCalendar=daily RandomizedDelaySec=1h AccuracySec=6h Persistent=true [Install] WantedBy=timers.target </pre> <p> 仓库地址和各种密码之类的放环境变量文件里了。把这个文件设置为只有 root 才能读,避免别的用户看到不该看的内容。然后 <code>systemctl enable --now sysbackup.timer</code> 就好了。</p> <p> 之后可以用 <code>restic snapshots</code> 查看备份的数据。使用 <code>restic forget</code> 删除旧的备份,比如 <code>restic forget -d 30 -w 10 -m 10 -l 5</code> 在最近30天、10周、10个月中各保留一份,外边最新的五个。这个命令只是删除这个备份的元数据,其关联的数据不会被删除。之后还要执行 <code>restic prune</code> 来实际删除数据&mdash;&mdash;这个过程会比较费时费力。加 <code>-v</code> 可以查看诸如减小了多少空间占用的数据。</p> <p> Restic 的备份是打包成块、压缩、加密存储的,带去重功能。但是增量备份会和主机名和路径相同的上一个备份进行比较。一个仓库里可以备份多台机器的相同或者不同的目录,但为了有效率地增量备份,每次增量备份时的目录不能变。这最后一点给我出了个难题。</p> <p> 我把 Arch Linux 中文论坛(以及维基)也用 Restic 备份到 sftp 目标里了。但这俩都是重数据库应用,因此直接对着 <code>/</code> 执行 <code>restic backup</code> 不行,数据有可能因为数据库的不同文件来自于不同的时间点而损坏。该系统在 btrfs 上并且有快照,对着快照备份就好了。问题是 Restic 执意不支持设置源路径,只能从文件系统上读取。我只好用 mount 命名空间把快照弄到 <code>/</code> 上再执行备份。脚本在这里:<a href="https://github.com/archlinuxcn/misc_scripts/blob/master/wiki/restic-backup-snapshot">https://github.com/archlinuxcn/misc_scripts/blob/master/wiki/restic-backup-snapshot</a>。由于权限的问题,用 bwrap 是不行的。对快照再做一个固定路径的可写快照来备份倒是能把路径固定到一个指定的位置,就是不太优雅,事后删掉快照也有额外的性能消耗。</p> <p> 关于备份数据的大小,<code>restic snapshots</code> 显示的是所有文件的名义大小的总和&mdash;&mdash;是压缩前的大小,并且硬链接会算多次。<code>restic stats --mode=restore-size</code> 则是按硬链接去重后的未压缩大小。<code>restic stats --mode=raw-data</code> 才是压缩后的、备份实际占用的空间。</p> <p> Restic 的备份仓库是加密的,因此无法直接查看,但它有 <code>restic mount</code> 子命令可以把备份仓库挂载了查看内容,就是性能比较差,只适合检查和恢复特定的少量文件。恢复大量数据推荐用 <code>restic restore</code>。另有 <code>restic diff</code> 命令可以查看不同快照之间的文件差异(新增、修改、删除了哪些文件,不含内容),方便在大小出现异常时查找元凶。</p> <p> 最后说说配置只能使用 sftp 的用户的方法。</p> <p> 首先在 <code>sshd_config</code> 里设置 <code>Subsystem sftp internal-sftp</code>。因为之后要进行 chroot,访问不到外部 sftp 服务的可执行文件的。然后设置指定的用户组只能用 sftp:</p> <pre class="brush: plain;"> Match Group sftp-users ChrootDirectory /srv/sftp X11Forwarding no AllowTcpForwarding no AllowAgentForwarding no ForceCommand internal-sftp -d /%u </pre> <p> 最后创建用户,按常规设置 <code>authorized_keys</code>,并按之前配置的路径,在 <code>/srv/sftp</code> 下创建该用户与其用户名同名的存储数据的目录,记得要 chown 到该用户。注意 <code>ChrootDirectory</code> 的目标目录(也就是这里的 <code>/srv/sftp</code>)应当只能由 root 写入(就跟 <code>/home</code> 目录那样)。</p>

2025/9/23
阅读更多

Arch Linux 中文论坛迁移杂记

<p style="text-indent: 2em; font-style: italic;">本文来自<a href="https://blog.lilydjwg.me/">依云's Blog</a>,转载请注明。</p><p> 本篇只是心得体会加上赞美和吐槽。<a href="https://wiki.archlinuxcn.org/maint/%E8%AE%BA%E5%9D%9B%E8%BF%81%E7%A7%BB">技术性的迁移记录在这里。</a></p> <h2> 起</h2> <p> 一个多月前,肥猫在 Arch Linux 中文群里说:</p> <blockquote> <p> (希望有好心人研究一下php74,最好加到archlinuxcn里,因为咱中文社区论坛卡在这个老版本了</p> </blockquote> <p> 然后话题自然就又到了论坛迁移的事情上来&mdash;&mdash;毕竟 <a href="https://github.com/fluxbb/fluxbb">FluxBB 年久失修</a>也不是一两天的事了。Arch Linux 官方<a href="https://gitlab.archlinux.org/archlinux/infrastructure/-/issues/257">讨论了几年还没结果</a>,但中文社区这边并不是卡在往哪迁上,而是</p> <blockquote> <p> 基于什么的都可以迁移,但得有人干活</p> </blockquote> <p> 然后又过了些天,论坛使用的本就递送困难的 Sendgrid <a href="https://www.twilio.com/en-us/changelog/sendgrid-free-plan">停止了免费服务</a>,导致中文论坛完全无法注册新用户了。虽然这件事通过更改为使用<a href="https://blog.lilydjwg.me/2024/10/24/deploy-a-mail-server-for-team.216887.html">我们自己的邮件服务器</a>就解决了,但迁移论坛的想法在我脑中开始成长。</p> <h2> 承</h2> <p> 至于迁移到哪个软件,我早已有了想法&mdash;&mdash;包括 <a href="https://forum.suse.org.cn/">OpenSUSE 中文社区</a>、<a href="https://forums.debiancn.org/">Debian 中文社区</a>、<a href="https://discourse.nixos.org/">NixOS</a>、<a href="https://forums.opensuse.org/">OpenSUSE</a>、<a href="https://discussion.fedoraproject.org/">Fedora</a>、<a href="https://discourse.ubuntu.com/">Ubuntu</a>、<a href="https://forum.manjaro.org/">Manjaro</a>、<a href="https://forum.garudalinux.org/">Garuda Linux</a>、<a href="https://discuss.cachyos.org/">CachyOS</a>、<a href="https://discuss.kde.org/">KDE</a>、<a href="https://discourse.gnome.org/">GNOME</a>、<a href="https://discuss.python.org/">Python</a>、<a href="https://users.rust-lang.org/">Rust</a>、<a href="https://forum.atuin.sh/">Atuin</a>、<a href="https://forum.f-droid.org/">F-Droid</a>、<a href="https://forum.openwrt.org/">OpenWrt</a>、<a href="https://community.letsencrypt.org/">Let&#39;s Encrypt</a>、<a href="https://discourse.mozilla.org/">Mozilla</a>、<a href="https://community.cloudflare.com/">Cloudflare</a>、<a href="https://community.grafana.com/">Grafana</a>、<a href="https://forums.docker.com/">Docker</a> 等等(还有一堆我没有那么熟悉的就不列了)大家都在用的 Discourse。这么多开源社区和商业组织都选择了它,试试总不会错的,用户也会比较熟悉。令我惊喜的是,它甚至有个 FluxBB 导入脚本。</p> <p> 于是在虚拟机里安装尝试。安装过程是由他们的脚本驱动构建的 docker 镜像,没什么特别的&mdash;&mdash;除了比较耗资源。Docker 嘛,硬盘要吃好几 GiB,然后它是现场编译前端资源文件,CPU 和内存也消耗不少。默认的构建是包含 PostgreSQL、Redis、Nginx 的,但 PostgreSQL 也可以用外边的,就是得监听 TCP,并且构建出来的镜像里依旧会存在 PostgreSQL 的服务文件。Nginx 可以改成监听 UNIX 域套接字,然后让外边我自己的 Nginx proxy_pass 过来,这样证书也按自己的方式管理。Redis 我本来也是想拆出来的,但是为安全起见要设个密码嘛,然后构建就失败了&hellip;&hellip;不过算了,反正也没别人用 Redis。</p> <p> 本地测的没有问题,于是去服务器上部署。由于发现它比较吃资源,所以给服务器加了几十G内存、100G 硬盘,还有闲置的 CPU 核心也分配上去了。后来发现运行起来其实也还好,就是内存吃得比较多&mdash;&mdash;每个 Rails 进程大几百,32个加起来就快 10G 了。运行起来之后 CPU 不怎么吃,甚至性能比 MediaWiki 要好上不少,以至于我把反 LLM 爬虫的机制给降级到大部分用户都不需要做了。哦它的 nginx 会起 <code>$(nproc)</code> 个,太多了,被我 sed 了一下,只留下了八只(但其实也完全用不上,毕竟是异步的;Rails 那些进程可是同步的啊)。</p> <pre class="brush: yaml"> run: - exec: sed -i &quot;/^worker_processes/s/auto/8/&quot; /etc/nginx/nginx.conf</pre> <p> 说回部署。跑起来是没什么问题的。问题出在那个 FluxBB 导入脚本&mdash;&mdash;本来导入就不快,它还跑到一半崩了,修了还崩。然后它不支持导入个性签名、用户头像、置顶帖,还遇到著名的 MySQL「utf8 不 mb4」的问题。<a href="https://github.com/discourse/discourse/pull/34210">来来回回修了又改</a>,花了我好些天。等保留数据测试的时候,发现有好多帖子的作者变成「system」了。一查才发现我刻意没有导入被封禁的用户造成的。都已经配得差不多了,实在是不想删库重来,研究了一下,直接在 Rails 控制台写了段脚本更正了。这个 Rails 控制台能在 Rails 的上下文里交互式地执行代码,很方便改数据,我很喜欢,比 PHP 方便太多了!</p> <p> 另外这个导入脚本有一点好的是,它能够反复执行来更新数据&mdash;&mdash;虽然这种支持反复执行的操作在我写的脚本里是常有的事,基本上从头开始成本太高的都会有,但别人写的脚本能考虑到这一点的可太少了。</p> <p> 用户的密码也导入了!帖子的重定向服务也写好了!虽然后来发现用户个人页面是登录用户才能访问的,给它重定向没什么用&hellip;&hellip;反而是 RSS 重定向更有用,后来也补上了!</p> <p> 后来还发现有些用户名包含空格或者特殊符号啥的,被自动改名了。好在可以用邮箱认用户,不管了,等受影响的用户出现了再改。另外正式迁移之后才发现还有些数据没有迁移&mdash;&mdash;版块的对应关系、用户的主题订阅,不是很重要,算了。</p> <h2> 转</h2> <p> 然后就迁移结束啦~所有到旧论坛的访问全部重定向到新论坛啦~不过我还是保留了一个不跳转的后门以便有需要时回去看看。为此我折腾了好久神奇的 nginx 的配置文件,最终得到以下片段:</p> <pre class="brush: plain;"> set $up &#39;redir&#39;; if ($http_cookie ~ &quot;noredir=1&quot;) { set $up &#39;noredir&#39;; proxy_pass https://104.245.9.3; } if ($up = redir) { proxy_pass http://127.0.0.1:9009; } </pre> <p> 就是根据 cookie 来 proxy_pass 到不同的服务啦。这样就可以访问一下 <code>/noredir</code> 设置上 cookie,就可以访问旧论坛,再访问一下 <code>/yesredir</code> 清一下 cookie 就恢复跳转到新论坛了。</p> <p> 说起 nginx,Discourse 还在这方面坑了我一下。它文档里给的设置是:</p> <pre class="brush: plain;"> proxy_set_header Host $http_host; </pre> <p> 这个配置在 HTTP/3 时是坏的,应该用 <code>$host</code>。别问我 HTTP/2 也没有 Host 头啊,为什么它在用 HTTP/2 时就不会出错。我也不知道 &macr;\<em>(ツ)</em>/&macr;。</p> <p> 于是新论坛上线啦~很多中国大陆用户的首次加载时间变成几十秒啦&hellip;&hellip;还好这只是无缓存加载的时间,就当是下载软件了吧。之后每个标签页大约需要一两秒加载整个 <abbr title="single page application">SPA</abbr>,在不同页面之间跳转并不慢。而这代价付出之后的回报是更现代的界面、丰富的功能。相比于旧论坛,现在:</p> <ul> <li> 终于有手机版啦,甚至还支持 PWA,体验非常丝滑。</li> <li> 实时预览的 markdown + bbcode 编辑器,还支持上传图片!再也不会有用户问论坛怎么传图片和日志了!</li> <li> 编辑器还支持草稿功能!不用怕写一半弄丢了,甚至可以换个设备接着写。</li> <li> 代码块角落里有个复制按钮,我再也不需要拖半天鼠标来复制日志然后粘贴进 Vim 里分析了!</li> <li> 快速、简单的搜索体验,用户再也不会找不到搜索功能在哪里了!</li> <li> 实时显示在回复的人。有人在回复的时候就可以等一等,发布帖子之后会立刻出现。发帖不需要跳转到不知道干什么的跳转页面了,读帖也不需要反复刷新了。</li> <li> 有收藏功能了,用户不用发一个根本没什么用的「mark 一下」的帖子了。</li> <li> 有「标记为已解决」的功能了。用户再也不需要问怎么把帖子标记为「已解决」了。</li> <li> 不用自己跑脚本解析 HTML 来向群里发新帖通知了。Discourse 的「聊天集成」功能配一下就好了。</li> <li> 甚至还有「RSS Polling」插件,可以把主站新闻转到论坛里,方便大家讨论。</li> </ul> <p> Discourse 的邮件集成功能也挺不错的。配好之后,可以检测到退信,也可以直接回复邮件通知来回帖。甚至还有个邮件列表模式,就是把所有帖子都给用户发一遍,用户也可以直接回帖。通过邮件发布新主题的功能也有,但我没有启用&mdash;&mdash;不同版块需要配不同的收件地址,有点麻烦,我不觉得有人会想用&hellip;&hellip;就是这个邮件传回 Discourse 部分坑了我一把,但不是 Discourse 的错。</p> <p> 是 maddy 的文档太缺欠了。我要把 forum+...@archlinuxcn.org 这种地址给重写到 noreply@archlinuxcn.org,按例子像这样</p> <pre class="brush: plain;"> table.chain local_rewrites { optional_step regexp &quot;forum\+(.+)@(.+)&quot; &quot;noreply@$2&quot; optional_step regexp &quot;(.+)\+(.+)@(.+)&quot; &quot;$1@$3&quot; optional_step static { entry postmaster postmaster@$(primary_domain) } optional_step file /etc/maddy/aliases step sql_query { driver postgres dsn &quot;user=maddy host=/run/postgresql dbname=maddy sslmode=disable&quot; lookup &quot;SELECT mailname FROM mailusers.mailinfo WHERE $1 = ANY(alias) and new = false&quot; } } </pre> <p> 这里第二行是我加的(虽然一开始把 <code>$2</code> 照着下边那个已有的写成了 <code>$3</code>)。结果是报错「用户不存在」、被退信。我开 debug 选项研究了好久,才意识到最后一步写的是 <code>step</code>,所以它总是要执行的&mdash;&mdash;然而 noreply 这个用户并不在数据库里,所以就找不到了。</p> <p> 那把最后一行改成 <code>optional_step</code> 就好啦&mdash;&mdash;我是这么想的,也是这么做的。然后就有人报告说 admin 邮箱拒收邮件了&hellip;&hellip;又是一通研究,才发现因为这里的步骤全是 <code>optional_step</code>,所以 maddy 第一次用整个邮件地址来查的时候,无论如何都是会通过的&mdash;&mdash;不会返回「目标不存在」,所以也就不会触发去掉域名、只用用户名查询的步骤,而数据库里记录的只有用户名,就导致 admin 邮箱的映射查不到了(映射到它本身,然而并没有以它为名的邮箱)。把 SQL 查询那一行改成这样子就好了:</p> <pre class="brush: plain;"> lookup &quot;SELECT mailname || &#39;@archlinuxcn.org&#39; FROM mailusers.mailinfo WHERE regexp_replace($1, &#39;@archlinuxcn.org$&#39;, &#39;&#39;) = ANY(alias) and new = false&quot; </pre> <p> 然后是把收件的邮件交给 Discourse。他们有个 <a href="https://github.com/discourse/mail-receiver">mail-receiver</a> 容器用来干这事,但这个容器的主要部分其实是 Postfix。我读了一下它的代码,实际上只需要把邮件通过 API 发过去就行了。于是我用 Python 写了一个服务&mdash;&mdash;<a href="https://github.com/archlinuxcn/imap2discourse">imap2discourse</a>。这部分的坑在于,这个 API 是把邮件全文用指定的参数 base64 或者不 base64 用 form-data 传过去的,我以为是用上传文件的方式来传,搞了半天它都报奇奇怪怪的错,后来一步一步在 irb 里按 mail-receiver 的代码对照检查,才发现原来是按传字符串的方式传的&hellip;&hellip;</p> <p> Discourse 的中文翻译不怎么样,好多随意的空格,也好多看不懂的翻译。好在它像 MediaWiki,支持修改界面文本。于是就一点一点地修了好多。上游使用的是 Crowdin 翻译平台,并不能直接 pr 翻译,所以等我什么时候研究一下才能把翻译贡献给上游了。</p> <p> Discourse 的通知功能挺全面的。可以选择回帖之后要不要通知,邮件通知是只在没访问时发、完全不要还是全都要,网站图标上要不要显示通知计数,还可以开启浏览器的推送通知(然后我就发现 Android 火狐的推送通知无法切换到 PWA 窗口)。</p> <p> 至于管理功能,比 FluxBB 丰富好用太多啦~有各种访问统计报表。设置项有搜索功能。有管理员操作日志,也有选项变更日志,还有邮件收发日志<del>(看看谁又把自己的邮箱域名拼错了)</del>。能给用户添加备注,也能切换成指定的用户看看他们看到的论坛是什么样子的。能给用户添加字段,让用户填写他们用的操作系统和桌面环境,省得回帖时经常要询问。还能加载自定义的 JavaScript 和 CSS,甚至是加强版本。还有暂时用不上的 API 和 webhook。哦对了,我发现它还会自己拒绝一些常见的讨厌爬虫。</p> <h2> 合</h2> <p> 除了 FluxBB 之外,还有一个叫 planetplanet 的 RSS 聚合软件也是死了好多年,导致 <a href="https://planet.archlinuxcn.org/">planet.archlinuxcn.org</a> 多年不更新了。Discourse 正好有从 RSS 发帖的功能,于是将星球也复活了一下,将大家的 RSS 作为帖子在<a href="https://forum.archlinuxcn.org/c/general/planet/35">专门的版块</a>发出。虽然界面不是很理想,但将就着用啦。<a href="https://forum.archlinuxcn.org/c/general/planet/35.rss">RSS 聚合</a>也是有的。Discourse 的 RSS 功能相当完善,几乎<a href="https://meta.discourse.org/t/finding-discourse-rss-feeds/264134">在所有合理的网址后边添加 .rss</a> 就能订阅。</p> <p> 也给旧论坛做了个静态存档站。暂时还没上线<del>,因为肥猫又跑掉了</del>。</p> <p> Discourse 的备份功能会报错,因为它的容器里的 pg_dump 版本比较旧,和我在外边 Arch Linux 里运行的版本不一致。不过我觉得这样也挺好的&mdash;&mdash;因为管理员是可以生成和下载备份文件的,也就是说,如果有管理员的权限被人恶意获取,那么他就能通过下载备份文件的方式获取整个 Discourse 数据库的内容。备份不了就少了这么个风险啦。当然备份我肯定是做了的,至于是如何做的,就等下一篇啦。</p>

2025/8/26
阅读更多

pacfiles: 高速的 pacman -F 替代品

<p style="text-indent: 2em; font-style: italic;">本文来自<a href="https://blog.lilydjwg.me/">依云's Blog</a>,转载请注明。</p><h2> 缘起</h2> <p> Linux 发行版的软件包管理器通常都会提供这么一个功能&mdash;&mdash;查找文件在哪个仓库中存在的软件包里。实现起来也挺简单:仓库维护一个每个软件包里都有哪些文件的数据库,软件去查就可以了&mdash;&mdash;假如用户不介意性能问题的话。</p> <p> 最开始,我使用的是 <a href="https://github.com/falconindy/pkgfile">pkgfile</a>。它是使用 C++ 编写的,会把 Arch 官方提供的 .files 数据库(压缩的 tar 归档)转成 cpio 归档再用(压缩可以靠 btrfs,问题倒是不大)。它比 pacman -F 可快多了,但是我后来不用了,因为它当时不支持多架构&mdash;&mdash;即在 pacman.conf 里把 Architecture 设置为多个值,比如我用的 <code>x86_64 x86_64_v3</code>。现在等我写好了 pacfiles,才发现它终于<a href="https://github.com/falconindy/pkgfile/commit/7baeadc939437b6bad6c6b12a58952765a27998d">在大半年之前支持多架构了</a>&hellip;&hellip;不过它看起来开发还是不太活跃,选项和输出格式也和 pacman -F 有很大的差别。</p> <h2> 效果对比</h2> <p> 最主要的功能是<strong>按文件名搜索</strong>,因此让我们先看看这个:</p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/pacfiles/pacman-search.png"><img alt="pacman -F vim 截图" src="https://store.lilydjwg.me/img/blog/pacfiles/pacman-search.png" /></a></p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/pacfiles/pkgfile-search.png"><img alt="pkgfile vim 截图" src="https://store.lilydjwg.me/img/blog/pacfiles/pkgfile-search.png" /></a></p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/pacfiles/pacfiles-search.png"><img alt="pacfiles -F vim 截图" src="https://store.lilydjwg.me/img/blog/pacfiles/pacfiles-search.png" /></a></p> <p> pacman -F 和 pkgfile 都是遍历整个数据库。pacman -F 和 pacfiles 是单线程的,pkgfile 是多线程,但我不知道为什么 pacman -F 会慢那么多。pkgfile 比 pacfiles 快一些,毕竟它提供的信息少、又不好看、还是多线程并行工作。另外值得注意的是,pacman -F 由于会预先加载整个数据库到内存,因此内存占用了近 3G。</p> <p> 有时候也会想要<strong>按完整路径搜索</strong>:</p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/pacfiles/pacman-search-fullpath.png"><img alt="pacman -F /usr/bin/vim 截图" src="https://store.lilydjwg.me/img/blog/pacfiles/pacman-search-fullpath.png" /></a></p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/pacfiles/pkgfile-search-fullpath.png"><img alt="pkgfile /usr/bin/vim 截图" src="https://store.lilydjwg.me/img/blog/pacfiles/pkgfile-search-fullpath.png" /></a></p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/pacfiles/pacfiles-search-fullpath.png"><img alt="pacfiles -F /usr/bin/vim 截图" src="https://store.lilydjwg.me/img/blog/pacfiles/pacfiles-search-fullpath.png" /></a></p> <p> 这次 pacfiles 因为有索引的帮助,并且不需要检查软件包是否已安装,比 pkgfile 快了不少。pacman -F 依旧又慢又吃内存。</p> <p> 接下来看看<strong>输出软件包的文件列表</strong>。这个由于输出结果多、输出格式又都差不多,我就重定向扔掉了,只看性能数据。</p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/pacfiles/pacman-list.png"><img alt="pacman -Fl vim-lily 截图" src="https://store.lilydjwg.me/img/blog/pacfiles/pacman-list.png" /></a></p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/pacfiles/pkgfile-list.png"><img alt="pkgfile -l vim-lily 截图" src="https://store.lilydjwg.me/img/blog/pacfiles/pkgfile-list.png" /></a></p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/pacfiles/pacfiles-list.png"><img alt="pacfiles -l vim-lily 截图" src="https://store.lilydjwg.me/img/blog/pacfiles/pacfiles-list.png" /></a></p> <p> 这次 pkgfile 比 pacfiles 略快。</p> <p> 有时候也会想<strong>用正则搜索</strong>:</p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/pacfiles/pacman-regex.png"><img alt="pacman -F --regex '.*libpython3\.11.*'" src="https://store.lilydjwg.me/img/blog/pacfiles/pacman-regex.png" /></a></p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/pacfiles/pkgfile-regex.png"><img alt="pkgfile --regex '.*libpython3\.11.*'" src="https://store.lilydjwg.me/img/blog/pacfiles/pkgfile-regex.png" /></a></p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/pacfiles/pacfiles-regex.png"><img alt="pacfiles -F --regex '.*libpython3\.11.*'" src="https://store.lilydjwg.me/img/blog/pacfiles/pacfiles-regex.png" /></a></p> <p> 这次 pkgfile 比 pacfiles 快了不少。使用正则搜索时,pacfiles 没有使用索引,也是遍历数据,所以快不起来了。</p> <p> 不过 pacfiles 是支持<strong>通配符搜索</strong>的,也能用上索引,很快的。pacman -F 不支持这个。而 pkgfile 嘛&hellip;&hellip;它不仅慢,好像还又出 bug 了。</p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/pacfiles/pkgfile-glob.png"><img alt="pkgfile -g '*libpython3.11*'" src="https://store.lilydjwg.me/img/blog/pacfiles/pkgfile-glob.png" /></a></p> <p style="text-align: center;"> <a href="https://store.lilydjwg.me/img/blog/pacfiles/pacfiles-glob.png"><img alt="pacfiles '*libpython3.11*'" src="https://store.lilydjwg.me/img/blog/pacfiles/pacfiles-glob.png" /></a></p> <p> 如果我写 pacfiles 之前得知 pkgfile 修了多架构那个 bug,我也许就不会写 pacfiles 了。不过现在对比下来,我也不后悔啦。</p> <p> 另外值得注意的是,pacfiles 无论是输出、还是命令行选项,都尽力兼容 pacman -F 的,以方便用户迁移。</p> <h2> 幕后</h2> <p> 其实我很早就想弄一个更快的 pacman -F 了。我首先想到的是,把数据塞进 SQLite3 里让它查。性能确实是好得不得了,但是一看生成的数据库,好几个 G&hellip;&hellip;后来又尝试像 pacman -F 那样直接读压缩包,但是不一次性加载到内存,因此不需要那么多内存。但结果并不理想:解压和遍历搜索都不太能快得起来,最多并行处理多个数据库而已。plocate 是很快啦,但是它的数据结构是自己定制的,并不是库,不能直接拿来用。于是此事便放下了。</p> <p> 直到前不久,我读到<a href="https://blog.startifact.com/posts/succinct/">《Succinct data structures》</a>一文,特别是文中提到的 FM-index&mdash;&mdash;这不正好能用来搜索文件名吗?不过,plocate 用的是什么数据结构来着?于是我去翻代码恢复了一下久远的记忆。哦,是 zstd 压缩的 trigram 倒排索引啊。好像也不错,还支持通配符呢。正则搜索它倒是没用上索引,因为作者认为「使用 locate 进行正则搜索太小众了」所以没有花精力去实现。</p> <p> 但是,以上关于数据结构的内容都不是重点!重点是,我发现了个 plocate-build 命令!它支持从纯文本创建 plocate 数据库!那我不是直接把文件名传给它就好了嘛~唯一有点遗憾的是,它不支持从管道读取文件名列表,因此需要先输出到临时文件中再给它使用,过程中会占用不少内存(/tmp 空间)。至于查询,调用 plocate 命令拿到结果再稍微处理一下就好了。于是想到就做,这就有了现在的 pacfiles(其实早期版本也在 git 历史里有)。</p> <p> 项目地址:<a href="https://github.com/lilydjwg/pacfiles">https://github.com/lilydjwg/pacfiles</a>。AUR 有 <a href="https://aur.archlinux.org/packages/pacfiles-git">pacfiles-git</a> 包。也可以 <code>cargo install pacfiles</code> 安装。</p>

2025/3/5
阅读更多

用 Android 手机当电脑的话筒

<p style="text-indent: 2em; font-style: italic;">本文来自<a href="https://blog.lilydjwg.me/">依云's Blog</a>,转载请注明。</p><p> 我之前是使用 ROC 来做这件事的。手机上安装 <a href="https://github.com/roc-streaming/roc-droid">roc-droid</a>,电脑上安装 pipewire-roc 然后执行 <code>pactl load-module module-roc-source source_name=roc-source</code> 就行。</p> <p> 但是这样会有一个问题:手机上的 roc-droid 会被休眠。换手机之前用的 Android 10 还好一点,可以设置半小时的「超长」关屏时间,并且屏幕关闭之后 roc-droid 还能活跃一段时间。现在换 Android 14 了,关屏之后 roc-droid 会立刻被休眠,也不能把 roc-droid 切到后台,否则录音会停止。为了让录音不中断,只能让手机<a href="https://play.google.com/store/apps/details?id=moe.zhs.caffeine">「喝点咖啡因」</a>来保持亮屏,于是不光网络和录音费电,屏幕也要费电。其实这个问题不是不能解决,放个持久通知就可以了,但是我不会 Android 开发呀。</p> <p> ROC 方案另外的小问题有:网络会持续占用,即使没在使用。手机要么录音、要么播放,需要手工切换。roc-droid 时不时会崩溃。</p> <p> 后来从群友那里了解到可以在 termux 里跑 PulseAudio,我试了试,比 ROC 方案好用多啦。</p> <p> 手机上除了需要安装 termux 和 pulseaudio 外,还需要安装 <a href="https://f-droid.org/packages/com.termux.api/">Termux:API</a>。为了方便启动,我还安装了 <a href="https://f-droid.org/packages/com.termux.widget/">Termux:Widget</a>。记得给 Termux:API 话筒权限。然后编辑 PulseAudio 配置文件 <code>/data/data/com.termux/files/usr/etc/pulse/default.pa.d/my.pa</code>:</p> <pre class="brush: plain;" title="my.pa"> load-module module-sles-source load-module module-native-protocol-tcp auth-ip-acl=电脑的IP地址 auth-anonymous=true </pre> <p> 这里的 sles 模块是用来录音的。</p> <p> 编辑 <code>/data/data/com.termux/files/usr/etc/pulse/daemon.conf</code> 文件,设置一小时不用才自动退出(默认20秒太短了):</p> <pre class="brush: plain;" title="daemon.conf"> exit-idle-time = 3600 </pre> <p> 然后在需要的时候执行 <code>pulseaudio</code> 命令就可以了。</p> <p> 电脑上的话,其实设置 <code>PULSE_SERVER</code> 环境变量就可以用上了。不过为了更好的集成,我们创建个 tunnel:</p> <pre class="brush: bash;"> pactl load-module module-tunnel-source server=tcp:手机的IP地址 </pre> <p> source 就是把手机当话筒用,改成 sink 的话则是把手机当音箱用了。</p> <p> 执行之后,在 PulseAudio / PipeWire 里就会多出来相应的 source(或者 sink)设备了。想怎么用就可以怎么用了~</p> <p> 但若是要同时使用另外的音箱来播放声音的话,手机话筒会把音箱播放的声音录进去,造成「回声」。这时候,就需要设置一下回声消除了。我参考了 <a href="https://wiki.archlinux.org/title/PipeWire/Examples#Echo_cancellation">ArchWiki</a>,PipeWire 配置如下:</p> <pre class="brush: plain;" title="60-echo-cancel.conf"> context.modules = [ { name = libpipewire-module-echo-cancel args = { monitor.mode = true source.props = { node.name = &quot;source_ec&quot; node.description = &quot;Echo-cancelled source&quot; } } } ] </pre> <p> 然后去 pavucontrol 里设置一下它生成的两个录音操作的设备(一个是选话筒,另一个是选外放的音箱的 monitor 设备),并把消除了回声的 source 设备设置为默认音频输入设备就好了。</p>

2025/1/11
阅读更多

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

<p style="text-indent: 2em; font-style: italic;">本文来自<a href="https://blog.lilydjwg.me/">依云's Blog</a>,转载请注明。</p><p> 我喜欢用本地文件听歌:没有广告、没有延迟、没有厂商锁定。但是有个问题:有的歌曲文件音量挺大的,比如 GARNiDELiA 和桃色幸运草Z的都感觉特别吵,需要调小音量,但有的音量又特别小,以至于我时常怀疑音频输出是不是出了问题。</p> <p> 这时候就要用到响度归一化了。响度衡量的是人的主观感知的音量大小,和声强&mdash;&mdash;也就是声波的振幅大小&mdash;&mdash;并不一样。ffmpeg 自带了一个 <code>loudnorm</code> 过滤器,用来按 EBU R128 标准对音频做响度归一化。于是调整好参数,用它对所有文件跑一遍就好了&mdash;&mdash;我最初是这么想的,也是这么做的。</p> <p> 以下是我最初使用的脚本的最终改进版。是的,改进过好多次。小的改进如排除软链接、反复执行时不重做以前完成的工作;大的改进如使用 sem 并行化、把测量和调整两个步骤分开。之所以有两个步骤,是因为我要线性地调整响度&mdash;&mdash;不要让同一个音频不同部分受到不同程度的调整。第一遍是测量出几个参数,这样第二遍才知道怎么调整。只过一遍的是动态调整,会导致调整程度不一,尤其是开头。</p> <p> 至于参数的选择,整体响度 <code>I=-14</code> 听说是 YouTube 它们用的,而真峰值 <code>TP=0</code> 和响度范围 <code>LRA=50</code> 是因为我不想给太多限制。</p> <pre class="brush: bash;"> #!/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 &quot;Processing $f&quot; export f json sem -j+0 &#39;ffmpeg -i $f -af loudnorm=print_format=json -f null /dev/null &lt;/dev/null |&amp; sed -n &#39;&#39;/^{$/,/^}$/p&#39;&#39; &gt; $json; echo &quot;Done with $f&quot;&#39; 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 &quot;Processing $f&quot; export f json output sem -j+0 &#39;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 &lt;/dev/null; echo &quot;Done with $f&quot;&#39; done sem --wait </pre> <p> 不得不说 zsh 的路径处理是真方便。相对地,sem 就没那么好用了。一开始我没加 <code>&lt;/dev/null</code>,结果 sem 起的进程全部 T 在那里不动,strace 还告诉我是 SIGTTOU 导致的&mdash;&mdash;我一直是 <code>-tostop</code> 的啊,也没见着别的时候收到 SIGTTOU。后来尝试了重定向 stdin,才发现其实是 SIGTTIN&mdash;&mdash;也不知道 ffmpeg 读终端干什么。另外,给 sem 的命令传数据也挺不方便的:直接嵌在命令里,空格啥的会出问题,最后只好用环境变量了。</p> <p> 等全部处理完毕,<code>for f in **/*_loudnorm.*; do ll -tr $f:r:s/_loudnorm//.$f:e $f; done | vim -</code> 看了一眼,然后就发现问题了:有的文件变大了好多,有的文件变小了好多!检查之后发现是编码参数变了:mp3 文件全部变成 128kbps 了,而 flac 的采样格式从 s16 变成了 s32。</p> <p> 于是又写了个脚本带上参数重新处理。这次考虑到以后我还需要对单个新加的歌曲文件处理,所以要处理的文件通过命令行传递。</p> <pre class="brush: bash;" title="loudnorm"> #!/bin/zsh -e doit () { local f=$1 local json=$f:r.json local output=$f:r_loudnorm.$f:e echo &quot;Processing $f&quot; if [[ -s $json || $f == *_loudnorm.* ]]; then else ffmpeg -i $f -af loudnorm=print_format=json -f null /dev/null &lt;/dev/null |&amp; sed -n &#39;/^{$/,/^}$/p&#39; &gt; $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 &#39;.streams[0].bit_rate&#39;) 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 &#39;.streams[0].profile&#39;) 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 &#39;.format.bit_rate&#39;) 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 &#39;.streams[0].bit_rate&#39;) if [[ $src_bitrate == null ]]; then src_bitrate=$(ffprobe -v error -select_streams a:0 -show_entries format=bit_rate -of json $f | jq -r &#39;.format.bit_rate&#39;) 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 &#39;.streams[0].sample_fmt&#39;) 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 &lt;/dev/null touch -r $f $output fi } for f in &quot;$@&quot;; do doit $f done </pre> <p> 然后我就神奇地发现,sem 不好用的问题突然没有了&mdash;&mdash;我直接 <code>parallel loudnorm ::: 文件们</code> 就好了嘛&hellip;&hellip;</p>

2024/12/11
阅读更多

为团队部署邮件服务

<p style="text-indent: 2em; font-style: italic;">本文来自<a href="https://blog.lilydjwg.me/">依云's Blog</a>,转载请注明。</p><p> 给服务器上的程序部署邮件服务十分简单,装个 Postfix 就搞定了。然而给人用的话就远远不够了。之所以要干这事,主要原因是之前使用的 Yandex 邮箱老出问题,丢邮件都算小事了,它还不让我登录 Web 界面,非要我填写我从未设置的密保问题的答案&hellip;&hellip;</p> <h2> 准备工作</h2> <p> 要部署邮件服务,首先当然要有域名和服务器了。需要注意的是,最好使用可以设置 PTR 记录的服务器,有些邮件服务器会要求这个。</p> <h2> 邮件传输代理</h2> <p> 这是最重要的部分。邮件传输代理,简称 MTA,是监听 TCP 25 端口、与其它邮件服务器交互的服务程序。我最常用的是 Postfix,给服务器上的程序用的话,它相当简单易用。但是要给它配置上 IMAP 和 SMTP 登录服务、以便给人类使用的话,就很麻烦。好在之前听群友说过 <a href="https://maddy.email/">maddy</a>,不仅能收发邮件,还支持简单的 IMAP 服务。唯一的缺点是不支持通过 25 端口发送邮件&mdash;&mdash;需要走 465 或者 587 端口,登录之后才能发件。它的账号系统也是独立于 UNIX 账号的,给程序使用需要额外的配置。</p> <p> 具体配置方面,首先是域名和 TLS 证书。我不知道为什么,它在分域名证书的选择上有些问题,最后我干脆全部用通配符证书解决了事。数据库我使用的是 PostgreSQL。要使用本地 peer 鉴权的话,需要把 <code>host</code> 的值设置为 PostgreSQL 监听套接字所在的目录,比如我是这样写的:</p> <pre class="brush: plain;"> dsn &quot;user=maddy host=/run/postgresql dbname=maddy sslmode=disable&quot; </pre> <p> PostgreSQL 监听套接字所在目录是编译时确定的。maddy 是 Go 写的,并不使用 libpq,因此它无法自动确定这个目录在哪里,需要手动指定。</p> <p> 关于邮箱别名,可以使用文本文件配置,也可以使用数据库查询指定。别名功能可以用来实现简单的邮件列表功能&mdash;&mdash;发往某一个地址的邮件会被分发到多个实际收件人的邮箱中。但是它不支持去重,也就是说,往包含自己的别名地址发送邮件,自己会额外收到一份。设置起来大概是这样子的:</p> <pre class="brush: plain;"> table.chain local_rewrites { optional_step regexp &quot;(.+)\+(.+)@(.+)&quot; &quot;$1@$3&quot; optional_step static { entry postmaster postmaster@$(primary_domain) } optional_step file /etc/maddy/aliases step sql_query { driver postgres dsn &quot;user=maddy host=/run/postgresql dbname=maddy sslmode=disable&quot; lookup &quot;SELECT mailname FROM mailusers.mailinfo WHERE $1 = ANY(alias) and new = false&quot; } } </pre> <p> 哦对了,那个 postmaster 地址需要手动合并,不然就要每个域名创建一个账号了。在别名文件里写上 <code>postmaster@host2: postmaster@host1</code> 就行了。</p> <p> maddy 会经常检查别名的修改时间然后自动重新加载,数据库查询当然是查出来是什么就是什么,所以还是比 Postfix 每次跑 <code>postalias</code> 命令要方便不少。</p> <h2> DNS 配置</h2> <p> 邮件域名的 MX 记录当然要设置上的。邮件服务器 IP 的 PTR 记录也要设置到服务器的域名上(A / AAAA 记录指到服务器)。SPF 的记录也不能忘。DMARC 和 DKIM 的记录没那么重要,不过推荐按 maddy 的文档设置上。</p> <p> 我还给域名设置 imap、imaps 和 submission 的 SRV 记录,但似乎客户端们并不使用它们。</p> <p> 这些设置好之后就可以去 https://email-security-scans.org/ 发测试邮件啦。</p> <h2> 反垃圾</h2> <p> maddy 内建对 rspamd 的支持,所以就用它好了。直接在 <code>smtp</code> 的 <code>check</code> 节里写上 <code>rspamd</code> 就好了。rspamd 跟着官方教程走,也基本不需要什么特别的设置,就是官方给的 nginx 配置有些坑人。我是这样设置的:</p> <pre class="brush: plain;"> 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; } </pre> <p> 注意这里给静态文件设置了过期时间,不然每次访问都要下载那些文件,非常慢。我是挂载在子路径下的,需要通过 <code>rewrite</code> 配置把子路径给删掉再传给 rspamd,不然会出问题。</p> <h2> 邮件客户端自动配置</h2> <p> 上边提到了 SRV 记录并不管用。实际上管用是在 <code>https://autoconfig.example.org/mail/config-v1.1.xml</code> 的配置文件。具体可以看 Lan Tian 的《<a href="https://lantian.pub/article/modify-website/write-config-file-let-thunderbird-autoconfig-domain-email.lantian/">编写配置文件,让 Thunderbird 自动配置域名邮箱</a>》这篇文章。</p> <h2> Web 邮件客户端</h2> <p> 使用的是 Roundcube,是一个 PHP 软件。可以跟着 <a href="https://wiki.archlinux.org/title/Roundcube">ArchWiki 的教程</a>配置。注意最好别跟着配置 <code>open_basedir</code>,因为会影响同一 php-fpm 实例上的其它服务。另外记得配过期时间,不然每次都要下载静态资源,很慢的。</p> <p> 因为上边部署了 rspamd 反垃圾服务,所以也可以给 Roundcube 启用一下 <code>markasjunk</code> 插件,并在 <code>/usr/share/webapps/roundcubemail/plugins/markasjunk/config.inc.php</code> 配置一下对应的命令:</p> <pre class="brush: php;"> $config[&#39;markasjunk_spam_cmd&#39;] = &#39;rspamc learn_spam -u %u -P PASSWORD %f&#39;; $config[&#39;markasjunk_ham_cmd&#39;] = &#39;rspamc learn_ham -u %u -P PASSWORD %f&#39;; </pre> <p> 不过我配置这个之后,命令会按预期被调用,但是 rspamd 的统计数据里不知为何总显示「0 Learned」。把垃圾邮件通过命令行手动喂给它又会提示已经学过该邮件了。</p>

2024/10/24
阅读更多

使用 nftables 屏蔽大量 IP

<p style="text-indent: 2em; font-style: italic;">本文来自<a href="https://blog.lilydjwg.me/">依云's Blog</a>,转载请注明。</p><p> 本来我是用 iptables 来屏蔽恶意IP地址的。之所以不使用 ipset,是因为我不想永久屏蔽这些 IP。iptables 规则有命中计数,所以我可以根据最近是否命中来删除「已经变得正常、或者分配给了正常人使用」的 IP。但 iptables 规则有个问题是,它是 O(n) 的时间复杂度。对于反 spam 来说,几千上万条规则问题不大,而且很多 spam 来源是机房的固定 IP。但是以文件下载为主、要反刷下行流量的用途,一万条规则能把下载速率限制在 12MiB/s 左右,整个 CPU 核的时间都消耗在 softirq 上了。perf top 一看,时间都消耗在 ipt_do_table 函数里了。</p> <p> 行吧,临时先加补丁先:</p> <pre class="brush: bash;"> iptables -I INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT </pre> <p> 这样让已建立的连接跳过后边上万条规则,就可以让正常的下载速度快起来了。</p> <p> 此时性能已经够用了。但是呢,还是时不时需要我手动操作一下,删除计数为零的规则、清零计数、合并恶意 IP 太多的网段。倒不是这些工作自动化起来有困难(好吧,让我用 Python 3.3 来实现可能是有些不便以至于至今我都没有动手),但是这台服务器上有新工具 nftables 可用,为什么不趁机试试看呢?</p> <p> 于是再次读了读 nft 的手册页,意外地发现,它竟然有个东西十分契合我的需求:它的 set 支持超时!于是开虚拟机对着文档调了半天规则,最终得到如下规则定义:</p> <pre class="brush: plain;"> destroy table inet blocker table inet blocker { set spam_ips { type ipv4_addr timeout 2d flags timeout, dynamic } set spam_ips6 { type ipv6_addr timeout 2d flags timeout, dynamic } chain input { type filter hook input priority 0; policy accept; ct state established,related accept ip saddr @spam_ips tcp dport { 80, 443 } update @spam_ips { ip saddr timeout 2d } drop ip6 saddr @spam_ips6 tcp dport { 80, 443 } update @spam_ips6 { ip6 saddr timeout 2d } drop } } </pre> <p> nftables 是自己创建 table 的,不用和别人「共用一张桌子然后打架」啦。然后定义了两个动态的、支持超时的、默认超时时间是两天的 set。nftables 的 table 可以同时支持 IPv4 和 IPv6,但是规则和 set 不行,所以得写两份。在 chain 定义中设置 hook,就跟 iptables 的默认 chain 一样可以拿到包啦。然后,已建立的连接不用检查了,因为恶意 IP 还没学会连接复用。接下来,如果源 IP 位于 set 内并且是访问 HTTP(S) 的话,就更新 set 的超时时间,然后丢弃包。限制端口是为了避免万一哪天把自己给屏蔽掉了。nftables 的规则后边可以写多个操作,挺直观、易于理解的。</p> <p> 然后让自己的恶意 IP 识别脚本用 <code>nft add element inet blocker spam_ips &quot;{ $IP }&quot;</code> 这样的命令向 set 里添加要屏蔽的 IP 就可以啦。两天不再有请求过来的 IP 会被自动解除屏蔽,很适合国内的三大运营商的动态 IP 呢。</p> <p> 跑了几天,被屏蔽的 IP 数量稳定在 26k&mdash;28k 之间。有昼夜周期,凌晨零点多和早上六七点是爆发期,晚间是静默期。性能非常好,softirq 最高占用不到 10%。</p> <p> nftables 也很好用。虽然 nft 的手册页有点难懂,多看几遍、了解其写作结构之后就好很多了。不过要是支持 IP 地址到 counter 的动态 map 就好了&mdash;&mdash;我想统计各 IP 的流量。nftables 还自带 Python 绑定,虽说这 API 走 JSON 感觉怪怪的,<a href="https://manpages.debian.org/testing/libnftables1/libnftables-json.5.en.html">libnftables-json(5)</a> 这文档没有超链接也很难使用,但至少弄明白之后能用。我用来写了个简单的统计脚本:</p> <pre class="brush: python;"> #!/usr/bin/python3 import os from math import log10 from itertools import groupby import nftables def show_set(nft, name): ret, r, error = nft.json_cmd({&#39;nftables&#39;: [{&#39;list&#39;: {&#39;set&#39;: {&#39;family&#39;: &#39;inet&#39;, &#39;table&#39;: &#39;blocker&#39;, &#39;name&#39;: name}}}]}) if ret != 0: raise Exception(ret, error) try: elements = r[&#39;nftables&#39;][1][&#39;set&#39;][&#39;elem&#39;] except KeyError: # empty set return ips = [(x[&#39;elem&#39;][&#39;val&#39;], x[&#39;elem&#39;][&#39;expires&#39;]) for x in elements] ips.sort(key=lambda x: x[1]) histo = [] total = len(ips) for k, g in groupby(ips, key=lambda x: x[1] // 3600): count = sum(1 for _ in g) histo.append((k, count)) max_count = max(x[1] for x in histo) w_count = int(log10(max_count)) + 1 w = os.get_terminal_size().columns - 5 - w_count count_per_char = max_count / w # count_per_char = total / w print(f&#39;&gt;&gt; Histogram for {name} (total {total}) &lt;&lt;&#39;) for hour, count in histo: print(f&#39;{hour:2}: {f&#39;{{:{w_count}}}&#39;.format(count)} {&#39;*&#39; * int(round(count / count_per_char))}&#39;) print() if __name__ == &#39;__main__&#39;: nft = nftables.Nftables() show_set(nft, &#39;spam_ips6&#39;) show_set(nft, &#39;spam_ips&#39;) </pre> <p> 最后,我本来想谴责用无辜开源设施来刷下行流量的行为的,但俗话说「人为财死」,算了。还是谴责一下运营商不顾社会责任、为了私利将压力转嫁给无辜群众好了。自私又短视的人类啊,总有一天会将互联网上的所有好东西都逼死,最后谁也得不到好处。</p>

2024/8/27
阅读更多