跳到正文
Hacker News· RyJones·· 6 天前AI 评分18

在 HP LaserJet M553 上部署 Let's Encrypt 证书,并用 Proxmox 容器自动续期

Show HN: Let's Encrypt on an HP LaserJet M553, Renewed from a Proxmox Container

AI 导读

一位用户为 HP Color LaserJet M553(FutureSmart 3,固件 2309118_002275)部署了 Let's Encrypt 证书,使其管理页面和 IPPS/AirPrint 均使用受信任证书,并由 Proxmox 上的 Alpine LXC 容器每两个月自动续期。

正文 · AI 翻译

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 容器需要等效的 dhclient enter-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 界面就足够规整,可以自动化:

  1. 身份证书是单选按钮,其值为 ID|<SHA-1 fingerprint>,大写十六进制且不带冒号。脚本会计算叶证书的指纹,并在不解析任何文本的情况下找到其证书。
  2. 用于网络身份会经过一个确认页面,其按钮为 Continue。之后 Web 服务器会重启,因此导航永远不会完成。脚本不会等待它;钩子通过 TLS 进行验证。
  3. 当所选证书已经是网络身份时,该按钮会被禁用。点击前请检查 disabled,否则你会等待一个永远不会到来的导航。
  4. 移除有其自己的确认页面,其按钮为 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