2024-12-11 11:43:45
本文来自依云's Blog,转载请注明。
我喜欢用本地文件听歌:没有广告、没有延迟、没有厂商锁定。但是有个问题:有的歌曲文件音量挺大的,比如 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 ::: 文件们
就好了嘛……
2024-10-24 15:04:32
本文来自依云's Blog,转载请注明。
给服务器上的程序部署邮件服务十分简单,装个 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
命令要方便不少。
邮件域名的 MX 记录当然要设置上的。邮件服务器 IP 的 PTR 记录也要设置到服务器的域名上(A / AAAA 记录指到服务器)。SPF 的记录也不能忘。DMARC 和 DKIM 的记录没那么重要,不过推荐按 maddy 的文档设置上。
我还给域名设置 imap、imaps 和 submission 的 SRV 记录,但似乎客户端们并不使用它们。
这些设置好之后就可以去 https://email-security-scans.org/ 发测试邮件啦。
maddy 内建对 rspamd 的支持,所以就用它好了。直接在 smtp
的 check
节里写上 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 自动配置域名邮箱》这篇文章。
使用的是 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」。把垃圾邮件通过命令行手动喂给它又会提示已经学过该邮件了。
2024-08-27 18:12:29
本文来自依云's Blog,转载请注明。
本来我是用 iptables 来屏蔽恶意IP地址的。之所以不使用 ipset,是因为我不想永久屏蔽这些 IP。iptables 规则有命中计数,所以我可以根据最近是否命中来删除「已经变得正常、或者分配给了正常人使用」的 IP。但 iptables 规则有个问题是,它是 O(n) 的时间复杂度。对于反 spam 来说,几千上万条规则问题不大,而且很多 spam 来源是机房的固定 IP。但是以文件下载为主、要反刷下行流量的用途,一万条规则能把下载速率限制在 12MiB/s 左右,整个 CPU 核的时间都消耗在 softirq 上了。perf top 一看,时间都消耗在 ipt_do_table 函数里了。
行吧,临时先加补丁先:
iptables -I INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
这样让已建立的连接跳过后边上万条规则,就可以让正常的下载速度快起来了。
此时性能已经够用了。但是呢,还是时不时需要我手动操作一下,删除计数为零的规则、清零计数、合并恶意 IP 太多的网段。倒不是这些工作自动化起来有困难(好吧,让我用 Python 3.3 来实现可能是有些不便以至于至今我都没有动手),但是这台服务器上有新工具 nftables 可用,为什么不趁机试试看呢?
于是再次读了读 nft 的手册页,意外地发现,它竟然有个东西十分契合我的需求:它的 set 支持超时!于是开虚拟机对着文档调了半天规则,最终得到如下规则定义:
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 } }
nftables 是自己创建 table 的,不用和别人「共用一张桌子然后打架」啦。然后定义了两个动态的、支持超时的、默认超时时间是两天的 set。nftables 的 table 可以同时支持 IPv4 和 IPv6,但是规则和 set 不行,所以得写两份。在 chain 定义中设置 hook,就跟 iptables 的默认 chain 一样可以拿到包啦。然后,已建立的连接不用检查了,因为恶意 IP 还没学会连接复用。接下来,如果源 IP 位于 set 内并且是访问 HTTP(S) 的话,就更新 set 的超时时间,然后丢弃包。限制端口是为了避免万一哪天把自己给屏蔽掉了。nftables 的规则后边可以写多个操作,挺直观、易于理解的。
然后让自己的恶意 IP 识别脚本用 nft add element inet blocker spam_ips "{ $IP }"
这样的命令向 set 里添加要屏蔽的 IP 就可以啦。两天不再有请求过来的 IP 会被自动解除屏蔽,很适合国内的三大运营商的动态 IP 呢。
跑了几天,被屏蔽的 IP 数量稳定在 26k—28k 之间。有昼夜周期,凌晨零点多和早上六七点是爆发期,晚间是静默期。性能非常好,softirq 最高占用不到 10%。
nftables 也很好用。虽然 nft 的手册页有点难懂,多看几遍、了解其写作结构之后就好很多了。不过要是支持 IP 地址到 counter 的动态 map 就好了——我想统计各 IP 的流量。nftables 还自带 Python 绑定,虽说这 API 走 JSON 感觉怪怪的,libnftables-json(5) 这文档没有超链接也很难使用,但至少弄明白之后能用。我用来写了个简单的统计脚本:
#!/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({'nftables': [{'list': {'set': {'family': 'inet', 'table': 'blocker', 'name': name}}}]}) if ret != 0: raise Exception(ret, error) try: elements = r['nftables'][1]['set']['elem'] except KeyError: # empty set return ips = [(x['elem']['val'], x['elem']['expires']) 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'>> Histogram for {name} (total {total}) <<') for hour, count in histo: print(f'{hour:2}: {f'{{:{w_count}}}'.format(count)} {'*' * int(round(count / count_per_char))}') print() if __name__ == '__main__': nft = nftables.Nftables() show_set(nft, 'spam_ips6') show_set(nft, 'spam_ips')
最后,我本来想谴责用无辜开源设施来刷下行流量的行为的,但俗话说「人为财死」,算了。还是谴责一下运营商不顾社会责任、为了私利将压力转嫁给无辜群众好了。自私又短视的人类啊,总有一天会将互联网上的所有好东西都逼死,最后谁也得不到好处。
2024-08-23 16:02:31
本文来自依云's Blog,转载请注明。
YubiKey 支持多种协议,或者说使用方式、模式,ykman 里称作「application」(应用程序)。很多程序支持多种 application。本文按 application 分节,记录一些自己的研究结果,并不全面。要全面的话,还请参考 ArchWiki 的 YubiKey 页面或者 YubiKey 官方文档。
在 Arch Linux 上,YubiKey 插上就可以用了,不需要特别的驱动方面的设置。有可能某些程序需要装个 libfido2 包来使用它。
插上之后,它会有一个键盘设备。摸一下,它就发送一长串字符然后回车。这串字符每次都不一样,并且需要与 Yubico 的服务器通信来验证是否有效。这串字符使用 AES 对称加密,也就意味着 Yubico 服务器也有私钥(你也可以自建服务器自己用)。
所以这是个没什么用的功能。并且在拔下设备时很容易误触,插到 Android 设备上之后输入法的屏幕键盘还可能不出来。所以我把它给禁用了:
ykman config mode FIDO+CCID
这个 application 在 Android 上第一次使用的时候会提示设置 PIN。我已经设置好了,也不知道在电脑上会如何。需要注意的是,这个 PIN 可以使用字母,并不需要是纯数字。最多可以连续输错八次,但没有 PIN 被锁之后用来解锁的 PUK。
插上就可以在火狐和 Google Chrome 里使用了。可以在 https://webauthn.io/ 测试。作为可以直接登录的 passkey 使用的话,会要求输入 PIN 和触摸。如果仅仅是作为二步验证使用(比如 GitHub),则只需要触摸即可。
Android 上也是差不多的。不过 Android 支持把 passkey 存储在设备里(还会通过 Google 账号同步),使用 YubiKey 时需要从弹窗中选取「使用其它设备」。如果网站已经在设备里存储了 passkey,那么没有使用 YubiKey 的机会。
OpenSSH 客户端需要安装 libfido2 包才能支持这些 -sk 结尾的 key。服务端不需要这个库。
有多个选项,具体参见 SSH Resident Key Guide。我总结了两种比较好的使用方式:
ssh-keygen -t ed25519-sk -O resident -O verify-required ssh-keygen -t ed25519-sk -O no-touch-required
可以选择的 key 类型只有ecdsa-sk
和ed25519-sk
,并不支持 RSA。resident
选项是把 key 存储到 YubiKey 上,之后可以通过ssh-keygen -K
下载回来。如果不加这个选项的话,那么仅凭 YubiKey 是无法使用的,得同时有生成的文件。verify-required
是验证 PIN。默认是需要触摸的,可以用no-touch-required
选项关闭,但是需要服务端在 authorized_keys 里设置这个选项。
从安全角度考虑,如果 YubiKey 丢失,那么仅凭该设备不应当能获得任何权限——所以在使用 resident 密钥时必须验证 PIN(我总不能赌偷或者捡到的人猜不中我的用户名或者访问的服务器吧)。这与自动化执行 SSH 命令相冲突。另一种使用方式,不需要 PIN、不需要触摸,倒是很方便自动化,也可以防止私钥被运行的程序偷走或者自己失误泄露,但是需要服务端设置no-touch-required
选项,而 GitHub 和 GitLab 并不支持。倒是可以不同场合使用不同的 key,但是管理起来太复杂了。
resident 密钥倒是可以使用 ssh-add 加载到 ssh-agent 里,之后应该就不需要交互即可使用了。但我现在启动系统要输入硬盘密码,登录到桌面并日常使用的话,还要输入用户密码和火狐主密码,已经够多了,不想再加一个 PIN。所以我还是不用了吧。
我倒是想给 termux 里的 ssh 用 YubiKey,毕竟手机上一堆乱七八糟的闭源程序,外加系统已经失去更新,感觉不怎么安全。但是搜了一圈看起来并不支持。
安装 pam_u2f 包,然后使用 pamu2fcfg 生成个文件。最后去改 PAM 配置就好啦,比如在 /etc/pam.d/sudo 开头加上
auth sufficient pam_u2f.so cue userpresence=1
这样会用触摸 YubiKey 来认证用户。如果把 YubiKey 拔了,pam_u2f 会被跳过。但是 YubiKey 正常的情况下,没有办法跳过 pam_u2f,所以通过 ssh 登录的时候会很难受……好吧,用 pam_exec 还是有办法跳过的,但是它似乎读不到环境变量,只能放个文件来控制,所以还是很麻烦。最好的办法是我在 pam_u2f 运行的时候按一下 Ctrl-C,它就放弃掉就好了,但这个 issue 已经等了快要六年了。
cryptsetup 并不直接支持 FIDO2。要使用 systemd-cryptenroll 来添加 keyslot:
sudo systemd-cryptenroll --fido2-device=auto /dev/disk/by-partlabel/XXX
可以用sudo cryptsetup luksDump /dev/disk/by-partlabel/XXX
命令看到 systemd 不光添加了一个 keyslot,还同时添加了一个 token 用于存储一些配置信息。
解密:
sudo systemd-cryptsetup attach XXX /dev/disk/by-partlabel/XXX '' fido2-device=auto
或者用 cryptsetup open 也行。但因为添加的 slot 是需要 PIN 的,cryptsetup open 不加 token 相关的选项时会跳过该 slot,直接问你密码。
sudo cryptsetup open --token-only /dev/disk/by-partlabel/XXX XXX
配置好之后,解密 LUKS 设备就不需要输入又长又复杂的密码啦。不过最好还是时不时验证一下自己还记得密码,要是需要用的时候才发现密码因为长期不用而遗忘了就不妙了。我的系统硬盘本来解密的次数就少,就不用它了,只给备份硬盘用了。
ykman 要管理 OpenPGP 智能卡应用,需要启用 pcscd 服务,但是 GnuPG 可以不用它。
sudo systemctl enable --now pcscd.socket
要让 ykman 和 GnuPG 能同时访问 YubiKey,可能还需要以下设置:
pcsc-driver /usr/lib/libpcsclite.so card-timeout 5 disable-ccid pcsc-shared
YubiKey 所有不同 application 的 PIN 是分开的。OpenPGP application 有 PIN 和管理 PIN,默认各能试三次。使用 key 的时候会用到 PIN,导入 key 的时候会用到管理 PIN。初次使用的时候记得用ykman openpgp access
命令把默认的 123456 和 12345678 都给改了(不知道为什么我没找到在gpg --card-edit
里更改管理 PIN 的方法)。导入的教程可以参考官方文档的 Importing keys。我的型号是 YubiKey 5C Nano,是支持 ed25519 / cv25519 算法的。
把 key 导入到 YubiKey 之后,可以再用ykman openpgp keys set-touch
设置一下哪些操作需要触摸。默认是都不需要的。然后正常使用就可以了。
要注意的是,YubiKey 只存储了私钥,所以本地要有公钥才可以正常使用。所以要换个系统使用的话,一种办法是把公钥上传到 OpenPGP 服务器上然后导入,另一种办法是自己导出成文件再导入。
SSH 也可以用 OpenPGP 密钥,所以也能用 YubiKey 上的 OpenPGP 密钥。甚至还能把现有的 ed25519 SSH key 导入进去用(不过我没有尝试)。
这个 PIV 涉及 PKCS#11,有点复杂。暂时不想研究。
2024-07-09 15:48:25
本文来自依云's Blog,转载请注明。
距离上次分享好久了,于是又来啦~
每一项第一行是扩展标题和链接,第二行是扩展自己的描述信息,第三行(如有)是我为写本文添加的介绍和评论。
由于获取方式的差异,这个列表没有扩展描述。不过大部分都和桌面版是重复的。
桌面版的列表是在 about:addons 页面,打开 devtools 执行以下代码取得的:
const r = $$('addon-card').map( (el) => { return { title: el.querySelector('h3').textContent, desc: el.querySelector('.addon-description').textContent, id: el.getAttribute('addon-id'), } } ) let parts = [] for(let ext of r) { parts.push(`<dt><a href="https://addons.mozilla.org/firefox/addon/${encodeURIComponent(ext.id)}/">${ext.title}</a></dt>\n<dd>${ext.desc}</dd>`) } console.log(parts.join('\n'))
而移动版是在 about:debugging 页面,连接上移动版火狐之后,执行以下代码获取的:
const r = $$('[data-qa-target-type="extension"]').map( (el) => { return { title: el.querySelector('[title]').title, id: el.querySelector('dd').textContent, } } ) let parts = [] for(let ext of r) { parts.push(`<dt><a href="https://addons.mozilla.org/android/addon/${encodeURIComponent(ext.id)}/">${ext.title}</a></dt>`) } console.log(parts.join('\n'))