在 HP LaserJet M553 上部署 Let's Encrypt 证书,并用 Proxmox 容器自动续期
Show HN: Let's Encrypt on an HP LaserJet M553, Renewed from a Proxmox Container
一位用户为 HP Color LaserJet M553(FutureSmart 3,固件 2309118_002275)部署了 Let's Encrypt 证书,使其管理页面和 IPPS/AirPrint 均使用受信任证书,并由 Proxmox 上的 Alpine LXC 容器每两个月自动续期。
HP LaserJet Enterprise 打印机出厂时自带自签名证书,且没有 ACME 客户端。我的打印机现在在其管理页面以及 IPPS/AirPrint 上提供 Let's Encrypt 证书,并且 Proxmox 上的一个小型 Alpine 容器每两个月自动续期,无需我接触打印机。以下是具体做法。
简而言之:
- 打印机 Web 界面中的一个设置,Use for Network Identity,同时覆盖了 443 端口上的 HTTPS 和
ipps://…/ipp/print。 - 证书通过 DNS-01 质询在其他地方签发。我在 Proxmox 上的 LXC 容器中运行 certbot。
- 密钥必须是 RSA。固件直接拒绝 EC 私钥。
- 上传是通过密码保护的
.pfx,使用openssl pkcs12 -legacy构建。 - 上传表单对脚本化 POST 返回 HTTP 500,因此续期钩子改用无头 Chromium 驱动 Web 界面。每次续期约 30 秒。
- 打印机只发送叶证书,从不发送中间证书。Apple 客户端、Chrome 和 Firefox 会自行获取。OpenSSL、Linux
curl、Python 和 Go 则不会。
硬件:HP Color LaserJet M553(FutureSmart 3),固件 2309118_002275,日期代码 20250112。其他 FutureSmart 型号行为应该相同。
为什么要费这个劲
macOS 会固定打印机的自签名证书。每次打印机重置并生成新证书时,打印就会中断,直到你从钥匙串中删除过期的证书并重新添加打印机。默认证书还以打印机的 mDNS 名称作为 CN,没有 subjectAltName,有效期为五年。使用打印机真实主机名的真实证书可以解决所有这些问题。
证书在打印机上的位置
https://<printer>/hp/device/CertificatesTabs/Index,或菜单中的 Security → Certificate Management。它提供自签名重新生成、CSR 流程、.pfx 导入、CA 存储以及 Use for Network Identity。
我选择了 .pfx 导入。CSR 流程将密钥保留在设备上,原则上更好,但 Let's Encrypt 每 60 天续期一次,每次续期都意味着要手动从打印机中提取新的 CSR。
CA 存储是打印机在与 SMTP 或 LDAP 通信时信任的内容。它对信任打印机的客户端没有任何作用,所以跳过它。务必在 Security → General Security 下设置管理员密码;出厂时没有密码。
Proxmox 容器
Certbot 需要一个始终在线且能访问打印机的主机,所以我使用 Proxmox。
在节点上:
pveam update
pveam download local alpine-3.24-default_20260301_amd64.tar.xz # pick whatever `pveam available` lists
pct create $(pvesh get /cluster/nextid) local:vztmpl/alpine-3.24-default_20260301_amd64.tar.xz \
--hostname printer-certs --ostype alpine --unprivileged 1 \
--cores 2 --memory 1024 --swap 0 --rootfs local-lvm:8 \
--net0 name=eth0,bridge=vmbr0,ip=dhcp \
--nameserver "<internal DNS 1> <internal DNS 2>" --searchdomain example.internal \
--onboot 1 --start 1
在内部(pct enter <id>):
apk add chromium nodejs npm certbot certbot-dns-rfc2136 openssl font-noto
mkdir -p /usr/local/lib/printer-cert && cd /usr/local/lib/printer-cert
npm install --no-audit --no-fund puppeteer-core # no bundled Chrome; use the apk one
Chromium、Node 和 certbot 在 8 GB 磁盘中占用约 600 MB。
在解析打印机内部名称之前,需要修复两个 Alpine 问题:
udhcpc会用 DHCP 服务器的解析器覆盖/etc/resolv.conf,忽略你给pct create的--nameserver。在/etc/udhcpc/udhcpc.conf中设置RESOLV_CONF="no"并手动写入/etc/resolv.conf。Debian 和 Ubuntu 容器需要等效的dhcliententer-hook。- musl 会并行查询每个名称服务器并取第一个答案。如果你的任何内部 DNS 服务器缺少打印机的记录,查找会间歇性失败。在所有服务器上添加该记录,并连续运行
getent hosts二十次后再信任它。
签发证书
打印机无法从互联网响应 HTTP-01,所以这是 DNS-01。我的区域接受带有 TSIG 密钥的 RFC 2136 动态更新;任何 certbot DNS 插件都可以。
/etc/letsencrypt/rfc2136.ini,权限 600:
dns_rfc2136_server = <IP address of the update server, not a hostname>
dns_rfc2136_port = 53
dns_rfc2136_name = <TSIG key name>
dns_rfc2136_secret = <TSIG secret>
dns_rfc2136_algorithm = HMAC-SHA256
certbot certonly --non-interactive --agree-tos --keep-until-expiring \
--dns-rfc2136 \
--dns-rfc2136-credentials /etc/letsencrypt/rfc2136.ini \
--dns-rfc2136-propagation-seconds 30 \
--key-type rsa --rsa-key-size 2048 \
-d printer.example.internal
主机名永远不需要公开解析。DNS-01 只需要公共区域中的 _acme-challenge TXT 记录;A 记录可以放在分离解析的内部 DNS 中。
为什么选择 RSA
Certbot 从 2.0 起默认使用 ECDSA,而这里 --key-type rsa 并非可选。用 openssl s_client 探测后发现,该打印机仅支持 TLS 1.2,不支持 1.3,且它唯一接受的 ECDHE 组是 P-256。所以它能做 ECDH,但能否持有 EC 密钥则是另一个问题。我用不同的名称构建了一次性自签名证书,尝试逐一导入,然后再删除:
| PFX | 密钥 | 编码 | 结果 |
|---|---|---|---|
| 对照 | RSA 2048 | 旧式 | 已接受 |
| P-256 | EC | 旧式 | “证书文件无效” |
| P-256 | EC | AES-256 / PBKDF2 | “证书文件无效” |
| P-384 | EC | 旧式 | “证书文件无效” |
RSA 对照组排除了自签名形式和 PKCS#12 编码作为原因的可能。Web 界面也以自己的方式说明了这一点:其 CSR 和自签名表单只提供“RSA 密钥长度:1024 位 / 2048 位”,别无其他。此固件无法生成或导入 EC 密钥。
构建 PFX
打印机对于带私钥的身份证书只接受 .pfx。OpenSSL 3 默认写入 AES-256/PBKDF2 的 PKCS#12,嵌入式固件通常无法读取。-legacy 恢复使用 3DES/RC2 和 SHA-1 MAC,此固件导入时没有报错:
openssl rand -base64 18 > pfx.pass
openssl pkcs12 -export -legacy \
-inkey privkey.pem -in cert.pem -certfile chain.pem \
-name printer.example.internal \
-passout file:pfx.pass -out printer.pfx
手动安装一次
在证书页面:导入带私钥的身份证书,选择 .pfx,输入密码,安装。然后选择新条目并点击用于网络身份。Web 服务器会重启。这一设置正是 443 端口上的管理界面和 ipps://…:443/ipp/print 共同使用的,因此 AirPrint 和 Web 界面会一起改变。
从客户端验证:
$ openssl s_client -connect printer.example.internal:443 -showcerts </dev/null | grep -E "s:|i:|Verify"
0 s:CN=printer.example.internal
i:C=US, O=Let's Encrypt, CN=YR2
Verify return code: 21 (unable to verify the first certificate)
$ curl -o /dev/null -w "%{http_code} %{ssl_verify_result}\n" https://printer.example.internal/ # macOS
200 0
$ ipptool -tv ipps://printer.example.internal/ipp/print get-printer-attributes.test | grep PASS
Get printer attributes using get-printer-attributes [PASS]
仅叶证书的问题
注意 OpenSSL 的返回码 21。打印机只提供一张证书,即叶证书,尽管 PFX 中包含完整链。将中间证书和根证书安装到打印机的 CA 存储中也不会改变这一点,断电重启也不行。据我所知,FutureSmart 从不发送中间证书。
Apple 客户端能正常工作,因为 Security 框架会跟随叶证书的 AIA 指针并自行下载中间证书,所以 Safari、AirPrint 和 macOS curl 都没问题。Chrome 和 Firefox 也会获取或缓存中间证书。严格的客户端则会失败:OpenSSL、Linux curl、Python requests、Go。对于这些客户端,可通过 SSL_CERT_FILE 或包含中间证书的 CA 包将其交给客户端,或者改用 631 端口上的普通 IPP 而非 IPPS 进行打印。
如果你的目标是 AirPrint 和一个没有浏览器警告的管理页面,这已经足够。如果你需要 Linux CUPS 客户端验证 IPPS,仅靠打印机无法实现。
自动化续期
Let’s Encrypt 证书有效期为 90 天,certbot 在 60 天时续期。容器中每天运行一次 cron 执行 certbot renew。续期配置带有一个部署钩子,certbot 仅在证书实际续期时才运行它,因此打印机大约每 60 天被操作一次,而 cron 每天运行。固定的“每 60 天”任务可能在 certbot 的窗口开启前一天触发,结果什么也不推送。
该钩子构建 PFX,运行一个 Node 脚本,在无头 Chromium 中驱动 Web 界面(导入、选择、用于网络身份、清理旧证书),然后检查打印机实际通过 TLS 提供的内容。
为什么用浏览器:表单提交到 /hp/device/CertificatesTabs/Save,而该处理程序对任何脚本化请求都返回 HTTP 500,即使 cookie、CSRF 令牌和浏览器标头都已就位。同一服务器上的登录表单可以正常接受脚本化 POST,所以这是该处理程序特有的问题。与其逆向工程它,不如让 Chromium 成为它所期望的浏览器。
部署钩子,/usr/local/sbin/printer-install-cert:
#!/bin/sh
set -eu
HP=${HP:-printer.example.internal}
LINEAGE=${RENEWED_LINEAGE:-/etc/letsencrypt/live/printer.example.internal} # certbot sets RENEWED_LINEAGE
EWS_PASS=/etc/printer-cert/ews-password # admin password, 0600
LOGDIR=/var/log/printer-cert
UPLOADER=/usr/local/lib/printer-cert/ews-upload.js
mkdir -p "$LOGDIR"
exec >>"$LOGDIR/install.log" 2>&1
echo "== $(date -Iseconds) start lineage=$LINEAGE"
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT; umask 077
openssl rand -base64 18 >"$WORK/pass"
openssl pkcs12 -export -legacy \
-inkey "$LINEAGE/privkey.pem" -in "$LINEAGE/cert.pem" -certfile "$LINEAGE/chain.pem" \
-name "$HP" -passout "file:$WORK/pass" -out "$WORK/printer.pfx"
SHA1=$(openssl x509 -in "$LINEAGE/cert.pem" -noout -fingerprint -sha1 | sed 's/.*=//; s/://g')
HP_SHOT_DIR=$LOGDIR node "$UPLOADER" "$HP" "$WORK/printer.pfx" "$WORK/pass" "$SHA1" "$EWS_PASS"
# the web server restarts after "Use for Network Identity"; poll until it serves the new leaf
i=0
while [ "$i" -lt 18 ]; do
SERVED=$(echo | openssl s_client -connect "$HP:443" -servername "$HP" 2>/dev/null \
| openssl x509 -noout -fingerprint -sha1 2>/dev/null | sed 's/.*=//; s/://g' || true)
[ "$SERVED" = "$SHA1" ] && { echo "OK printer serves $SHA1"; exit 0; }
i=$((i + 1)); sleep 10
done
echo "FAIL printer serves '${SERVED:-nothing}', expected $SHA1"; exit 1
PFX 密码每次运行随机生成且从不存储。非零退出会使 certbot renew 报告钩子失败,日志和截图会显示它停在哪里。
只要你了解它的四件事,这个 Web 界面就足够规整,可以自动化:
- 身份证书是单选按钮,其值为
ID|<SHA-1 fingerprint>,大写十六进制且不带冒号。脚本会计算叶证书的指纹,并在不解析任何文本的情况下找到其证书。 - 用于网络身份会经过一个确认页面,其按钮为 Continue。之后 Web 服务器会重启,因此导航永远不会完成。脚本不会等待它;钩子通过 TLS 进行验证。
- 当所选证书已经是网络身份时,该按钮会被禁用。点击前请检查
disabled,否则你会等待一个永远不会到来的导航。 - 移除有其自己的确认页面,其按钮为 Delete,而不是 OK。脚本会删除颁发给同一名称的所有其他身份证书,以免续期时堆积。
ews-upload.js 的核心:
const puppeteer = require('puppeteer-core');
const [host, pfx, pfxPassFile, sha1, ewsPassFile] = process.argv.slice(2);
const base = `https://${host}`;
const certsUrl = `${base}/hp/device/CertificatesTabs/Index`;
const radioSel = `input[name="certificatesRadioList"][value="ID|${sha1}"]`;
const browser = await puppeteer.launch({
executablePath: '/usr/bin/chromium-browser',
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage',
'--ignore-certificate-errors', '--disable-features=AsyncDns'],
});
const page = await browser.newPage();
const nav = () => page.waitForNavigation({ waitUntil: 'load' });
async function confirmIfAsked(labels) { // "Continue" or "Delete" pages
const btn = await page.$(labels.map((l) => `input[type="submit"][value="${l}"]`).join(', '));
if (!btn) return false;
await Promise.all([nav().catch(() => {}), btn.click()]);
return true;
}
if (ewsPass) {
await page.goto(`${base}/hp/device/SignIn/Index`, { waitUntil: 'load' });
await page.type('#PasswordTextBox', ewsPass);
await Promise.all([nav(), page.click('#signInOk')]);
}
await page.goto(certsUrl, { waitUntil: 'load' });
if (!(await page.$(radioSel))) { // import unless already there
await page.click('#ImportCertificateWithPrivateKey');
await (await page.$('#SignedCertFile')).uploadFile(pfx);
await page.type('#certPassword', pfxPass);
await Promise.all([nav(), page.click('#InstallButton')]);
}
await page.click(radioSel);
if (!(await page.$eval('#UseForNetButton', (b) => b.disabled))) {
await Promise.all([nav().catch(() => {}), page.click('#UseForNetButton')]);
await confirmIfAsked(['Continue', 'OK']); // web server restarts here
}
for (;;) { // prune superseded certificates for this name
await page.goto(certsUrl, { waitUntil: 'load' });
const stale = await page.evaluate((host, keep) => {
for (const r of document.querySelectorAll('input[name="certificatesRadioList"]')) {
const issuedTo = r.closest('tr')?.querySelector('td:nth-child(2)')?.textContent.trim();
if (r.value.startsWith('ID|') && r.value !== keep && issuedTo === host) return r.value;
}
return null;
}, host, `ID|${sha1}`);
if (!stale) break;
await page.click(`input[name="certificatesRadioList"][value="${stale}"]`);
await Promise.all([nav(), page.click('#RemoveButton')]);
if (!(await confirmIfAsked(['Delete']))) break;
}
await browser.close();
完整脚本还会在每一步后截图、重试页面加载,并且如果刚导入的证书不在列表中,就拒绝继续。--disable-features=AsyncDns 使 Chromium 使用系统解析器而非其自身的解析器。
一次实际运行:
== 2026-10-09T11:08:43+00:00 start lineage=/etc/letsencrypt/live/printer.example.internal
leaf B0A… expires Jan 7 10:07:29 2027 GMT
11:08:45 opening certificate management
11:08:50 importing PFX
11:08:53 import listed
11:08:53 selecting it for network identity
11:08:56 use-for-network: confirmation page, pressing Continue
11:09:04 done; the caller verifies what the printer now serves
OK 2026-10-09T11:09:15+00:00 printer serves B0A…
大约 30 秒,其中大部分时间是打印机在重启其 Web 服务器。对同一证书的第二次运行是幂等的:已存在、已是网络身份、无需修剪、验证。
通过将钩子添加到续期配置中,将其接入 certbot:
# /etc/letsencrypt/renewal/printer.example.internal.conf, under [renewalparams]
renew_hook = /usr/local/sbin/printer-install-cert
(certbot reconfigure --deploy-hook … 做同样的事,但坚持先成功进行一次 staging 试运行。编辑该文件就是它本来会写入的内容。)
以及 cron,Alpine 风格,/etc/periodic/daily/certbot-renew:
#!/bin/sh
exec /usr/bin/certbot renew --quiet
零碎事项
- 为打印机保留 DHCP 租约,或给它一个静态地址。证书指定了一个主机名,而该主机名必须始终指向打印机。
- 私钥会传输。使用 PFX 方式时,密钥存在于签发容器上,并短暂存在于临时目录中。将密钥文件权限设为 0600,并且不要让 PFX 在其他任何地方留存。
- 默认情况下,无管理员密码、telnet 开启,并且原始端口 9100 打印已启用。趁你在那里时把这三项都修复。
来源:Hacker News · blog.ryjones.org