[PR #1067] [CLOSED] fix(virtual-printer): Tailscale cert provisioning — hardening + maintainer feedback #1150

Closed
opened 2026-05-06 12:35:22 +02:00 by BreizhHardware · 0 comments

📋 Pull Request Information

Original PR: https://github.com/maziggy/bambuddy/pull/1067
Author: @legend813
Created: 4/21/2026
Status: Closed

Base: devHead: feature/tailscale-cert-integration


📝 Commits (10+)

  • af90380 feat(virtual-printer): add Tailscale certificate provisioning
  • 9116f83 fix(virtual-printer): harden Tailscale cert provisioning
  • 01d35bf test(virtual-printer): extend Tailscale test coverage
  • 2243249 feat(virtual-printer): add Tailscale status UI to virtual printer settings
  • b2251dc fix(virtual-printer): harden Tailscale cert provisioning
  • 95ad6a0 test(virtual-printer): extend Tailscale test coverage to 33 tests
  • d66ca43 fix(virtual-printer): improve Tailscale fallback logging and error detection
  • cd18226 test(virtual-printer): cover HTTPS-disabled detection and readability check
  • fe860aa Merge branch 'dev' into feature/tailscale-cert-integration
  • 66a17bd fix(virtual-printer): address maintainer review feedback on PR #1067

📊 Changes

20 files changed (+10423 additions, -51 deletions)

View changed files

📝 backend/app/api/routes/virtual_printers.py (+33 -0)
📝 backend/app/services/virtual_printer/certificate.py (+35 -2)
📝 backend/app/services/virtual_printer/manager.py (+150 -4)
backend/app/services/virtual_printer/tailscale.py (+333 -0)
backend/tests/integration/test_tailscale_api.py (+61 -0)
backend/tests/unit/services/test_tailscale.py (+919 -0)
📝 frontend/src/api/client.ts (+14 -1)
📝 frontend/src/components/VirtualPrinterCard.tsx (+7 -1)
📝 frontend/src/components/VirtualPrinterList.tsx (+38 -3)
📝 frontend/src/i18n/locales/de.ts (+6 -0)
📝 frontend/src/i18n/locales/en.ts (+6 -0)
📝 frontend/src/i18n/locales/fr.ts (+6 -0)
📝 frontend/src/i18n/locales/it.ts (+6 -0)
📝 frontend/src/i18n/locales/ja.ts (+6 -0)
📝 frontend/src/i18n/locales/pt-BR.ts (+6 -0)
📝 frontend/src/i18n/locales/zh-CN.ts (+6 -0)
📝 frontend/src/i18n/locales/zh-TW.ts (+6 -0)
static/assets/index-B5tNePLk.js (+8743 -0)
static/assets/index-b56DyY_w.css (+1 -0)
📝 static/index.html (+41 -40)

📄 Description

What this PR does

Adds automatic Tailscale TLS certificate provisioning for Virtual Printers. When Tailscale is installed and connected, Bambuddy obtains a Let's Encrypt certificate via tailscale cert and applies it to all VP TLS services (MQTTS, FTPS, Bind). SSDP then advertises the Tailscale FQDN instead of a local IP — so slicers connect via a hostname covered by a publicly trusted cert, eliminating manual CA installation.

Falls back silently to the existing self-signed cert when Tailscale is absent or provisioning fails.


Documentation

Wiki PR: maziggy/bambuddy-wiki#13 — adds a new Tailscale Certificate (Optional) section to the Virtual Printer page covering:

  • How the automatic cert provisioning works end-to-end
  • The per-VP toggle and when to disable it (LAN slicers without Tailscale access)
  • Docker setup: /var/run/tailscale socket bind-mount with annotated compose example
  • Proxy-mode limitation explained
  • Fallback behaviour table and two new troubleshooting entries

Why tailscale cert instead of tailscale serve

The original design discussion considered tailscale serve. After deeper analysis, tailscale serve is not viable for this protocol stack:

  • MQTTS (port 8883) — raw TLS over TCP; tailscale serve is an HTTPS reverse proxy and cannot terminate bare TLS sockets
  • FTPS (port 990) — same reason; requires raw TLS termination, not HTTP proxying

tailscale cert provisions a cert that the VP TLS listener loads directly, which works for all three protocols. This is the only technically compatible path for the full VP stack.


Review Feedback Addressed (maintainer review #4147826937)

Major issues

Issue Fix
Proxy-mode TLS/SSDP mismatch _resolve_cert_and_advertise() returns self-signed cert immediately in proxy mode; renewal loop not started for proxy VPs
LAN-slicer regression from auto-detect Addressed in companion PR #1070 (use_tailscale_cert toggle)
Docker deployment gap Docker socket hint logged; wiki PR #13 documents the volume-mount setup

Code-level fixes

Issue Fix
_cancel_renewal_task silences real bugs CancelledError and Exception now split; unexpected errors logged as warnings
Missing asyncio.TimeoutError handling get_status() and provision_cert() now catch TimeoutError and return safe defaults
Fire-and-forget restart task Stored as self._cert_restart_task; cancelled in stop_server() + stop_proxy()
Restart failure kills renewal loop _restart_for_cert_renewal() re-spawns the renewal loop in its except block

Architecture

New: TailscaleService (backend/app/services/virtual_printer/tailscale.py)

  • get_status() — runs tailscale status --json to get FQDN and Tailscale IPs; returns TailscaleStatus(available=False) on any error
  • provision_cert() — runs tailscale cert --cert-file … --key-file … <fqdn>; validates FQDN, creates output directory, sets key permissions to 0o600, verifies readability
  • cert_needs_renewal() — checks expiry against a 30-day threshold and validates FQDN against cert SANs (RFC 4343 case-insensitive)
  • ensure_cert() — combines renewal check + provisioning in one call
  • Security: binary resolved to absolute path via shutil.which() to prevent PATH hijacking; _SUBPROCESS_ENV allowlist strips JWT keys, DB URLs, and SMTP passwords from subprocess environment; asyncio.wait_for timeouts (5 s for status, 60 s for cert provisioning)

Changes to CertificateService (certificate.py)

  • New ts_cert_path / ts_key_path properties pointing to per-VP Tailscale cert files
  • New use_tailscale_cert(fqdn, tailscale_svc) method

Changes to VirtualPrinterInstance (manager.py)

  • New _resolve_cert_and_advertise() — proxy mode always uses self-signed; server mode tries Tailscale then falls back
  • New _cert_renewal_loop() — daily background task; schedules restart via _cert_restart_task on renewal
  • New _cancel_renewal_task() / _cancel_restart_task() helpers with proper exception logging
  • _restart_for_cert_renewal() re-spawns the loop if restart fails

New API endpoint

GET /api/v1/virtual-printers/tailscale-status — returns TailscaleStatusResponse; protected by RequirePermissionIfAuthEnabled(SETTINGS_READ)

Frontend (separate PR #1070)

  • Tailscale status card in VirtualPrinterList
  • ShieldCheck badge in VirtualPrinterCard when active
  • Per-VP use_tailscale_cert toggle in settings

Fallback behaviour

Scenario Behaviour
tailscale binary not found get_status() returns available=False; self-signed cert used
Daemon stopped / not logged in tailscale status exits non-zero; self-signed cert used
HTTPS certs not enabled for tailnet stderr parsed for disabled-certs pattern; actionable log points to admin DNS page; self-signed cert used
Rate-limited by Let's Encrypt Warning logged with exit code + stderr; self-signed cert used
tailscale cert times out TimeoutError caught; self-signed cert used
Cert files not readable (root-owned) os.access() check after write; actionable sudo chown log; self-signed cert used
Any other exception Caught at manager level; warning logged; self-signed cert used
Docker socket not mounted Info log with volume-mount instruction

Cert auto-renewal

  • Renewal check runs once per day while the VP is active
  • Renews when fewer than 30 days remain or FQDN no longer matches cert SANs
  • After successful renewal, the VP is restarted automatically — no manual intervention required
  • On error the loop backs off 1 hour; CancelledError on VP stop exits cleanly
  • Restart task tracked and cancelled cleanly if VP is deleted mid-restart

Test plan

  • pytest backend/tests/unit/services/test_tailscale.py -v — 43 tests, all pass
  • pytest backend/tests/unit/ -v — no regressions
  • Frontend: npm run build — clean, no TypeScript errors
  • Manual: start VP with Tailscale active → logs "[VP …] Using Tailscale cert for <fqdn>"
  • Manual: GET /api/v1/virtual-printers/tailscale-status returns FQDN and available: true
  • Manual: Tailscale disconnected → logs "Tailscale not available, using self-signed cert"
  • Manual: LAN slicer + toggle Off → discovery works without Tailscale access
  • Manual: Docker without socket mount → actionable log appears

Closes #701


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/maziggy/bambuddy/pull/1067 **Author:** [@legend813](https://github.com/legend813) **Created:** 4/21/2026 **Status:** ❌ Closed **Base:** `dev` ← **Head:** `feature/tailscale-cert-integration` --- ### 📝 Commits (10+) - [`af90380`](https://github.com/maziggy/bambuddy/commit/af90380a0603361453f47009049c11cedc3f7814) feat(virtual-printer): add Tailscale certificate provisioning - [`9116f83`](https://github.com/maziggy/bambuddy/commit/9116f830126cf5b605b89aaa90f4ea573468f21c) fix(virtual-printer): harden Tailscale cert provisioning - [`01d35bf`](https://github.com/maziggy/bambuddy/commit/01d35bfe69f1a433eb6fac8cee87cf89ae25c9bc) test(virtual-printer): extend Tailscale test coverage - [`2243249`](https://github.com/maziggy/bambuddy/commit/2243249083314c46b00b6aa44a9097b0cebfde84) feat(virtual-printer): add Tailscale status UI to virtual printer settings - [`b2251dc`](https://github.com/maziggy/bambuddy/commit/b2251dcebfb132e2ca9b8d5adac2edd37ee5583c) fix(virtual-printer): harden Tailscale cert provisioning - [`95ad6a0`](https://github.com/maziggy/bambuddy/commit/95ad6a07bbc063d59dc8935d468d51109d7bd5fb) test(virtual-printer): extend Tailscale test coverage to 33 tests - [`d66ca43`](https://github.com/maziggy/bambuddy/commit/d66ca4321f3bf769d1929223a1478e1635ba5f3d) fix(virtual-printer): improve Tailscale fallback logging and error detection - [`cd18226`](https://github.com/maziggy/bambuddy/commit/cd1822676a482f557400e3626c2fb2fc12b2114d) test(virtual-printer): cover HTTPS-disabled detection and readability check - [`fe860aa`](https://github.com/maziggy/bambuddy/commit/fe860aa85b8c5121fee00f1807c64d4ff17c5240) Merge branch 'dev' into feature/tailscale-cert-integration - [`66a17bd`](https://github.com/maziggy/bambuddy/commit/66a17bdc95d333a38a44c1b10e3a2965e0f5c4c2) fix(virtual-printer): address maintainer review feedback on PR #1067 ### 📊 Changes **20 files changed** (+10423 additions, -51 deletions) <details> <summary>View changed files</summary> 📝 `backend/app/api/routes/virtual_printers.py` (+33 -0) 📝 `backend/app/services/virtual_printer/certificate.py` (+35 -2) 📝 `backend/app/services/virtual_printer/manager.py` (+150 -4) ➕ `backend/app/services/virtual_printer/tailscale.py` (+333 -0) ➕ `backend/tests/integration/test_tailscale_api.py` (+61 -0) ➕ `backend/tests/unit/services/test_tailscale.py` (+919 -0) 📝 `frontend/src/api/client.ts` (+14 -1) 📝 `frontend/src/components/VirtualPrinterCard.tsx` (+7 -1) 📝 `frontend/src/components/VirtualPrinterList.tsx` (+38 -3) 📝 `frontend/src/i18n/locales/de.ts` (+6 -0) 📝 `frontend/src/i18n/locales/en.ts` (+6 -0) 📝 `frontend/src/i18n/locales/fr.ts` (+6 -0) 📝 `frontend/src/i18n/locales/it.ts` (+6 -0) 📝 `frontend/src/i18n/locales/ja.ts` (+6 -0) 📝 `frontend/src/i18n/locales/pt-BR.ts` (+6 -0) 📝 `frontend/src/i18n/locales/zh-CN.ts` (+6 -0) 📝 `frontend/src/i18n/locales/zh-TW.ts` (+6 -0) ➕ `static/assets/index-B5tNePLk.js` (+8743 -0) ➕ `static/assets/index-b56DyY_w.css` (+1 -0) 📝 `static/index.html` (+41 -40) </details> ### 📄 Description ## What this PR does Adds automatic Tailscale TLS certificate provisioning for Virtual Printers. When Tailscale is installed and connected, Bambuddy obtains a Let's Encrypt certificate via `tailscale cert` and applies it to all VP TLS services (MQTTS, FTPS, Bind). SSDP then advertises the Tailscale FQDN instead of a local IP — so slicers connect via a hostname covered by a publicly trusted cert, eliminating manual CA installation. Falls back silently to the existing self-signed cert when Tailscale is absent or provisioning fails. --- ## Documentation Wiki PR: maziggy/bambuddy-wiki#13 — adds a new **Tailscale Certificate (Optional)** section to the Virtual Printer page covering: - How the automatic cert provisioning works end-to-end - The per-VP toggle and when to disable it (LAN slicers without Tailscale access) - Docker setup: `/var/run/tailscale` socket bind-mount with annotated compose example - Proxy-mode limitation explained - Fallback behaviour table and two new troubleshooting entries --- ## Why `tailscale cert` instead of `tailscale serve` The original design discussion considered `tailscale serve`. After deeper analysis, `tailscale serve` is **not viable** for this protocol stack: - **MQTTS (port 8883)** — raw TLS over TCP; `tailscale serve` is an HTTPS reverse proxy and cannot terminate bare TLS sockets - **FTPS (port 990)** — same reason; requires raw TLS termination, not HTTP proxying `tailscale cert` provisions a cert that the VP TLS listener loads directly, which works for all three protocols. This is the only technically compatible path for the full VP stack. --- ## Review Feedback Addressed (maintainer review #4147826937) ### Major issues | Issue | Fix | |-------|-----| | Proxy-mode TLS/SSDP mismatch | `_resolve_cert_and_advertise()` returns self-signed cert immediately in proxy mode; renewal loop not started for proxy VPs | | LAN-slicer regression from auto-detect | Addressed in companion PR #1070 (`use_tailscale_cert` toggle) | | Docker deployment gap | Docker socket hint logged; wiki PR #13 documents the volume-mount setup | ### Code-level fixes | Issue | Fix | |-------|-----| | `_cancel_renewal_task` silences real bugs | `CancelledError` and `Exception` now split; unexpected errors logged as warnings | | Missing `asyncio.TimeoutError` handling | `get_status()` and `provision_cert()` now catch `TimeoutError` and return safe defaults | | Fire-and-forget restart task | Stored as `self._cert_restart_task`; cancelled in `stop_server()` + `stop_proxy()` | | Restart failure kills renewal loop | `_restart_for_cert_renewal()` re-spawns the renewal loop in its except block | --- ## Architecture ### New: `TailscaleService` (`backend/app/services/virtual_printer/tailscale.py`) - `get_status()` — runs `tailscale status --json` to get FQDN and Tailscale IPs; returns `TailscaleStatus(available=False)` on any error - `provision_cert()` — runs `tailscale cert --cert-file … --key-file … <fqdn>`; validates FQDN, creates output directory, sets key permissions to `0o600`, verifies readability - `cert_needs_renewal()` — checks expiry against a 30-day threshold and validates FQDN against cert SANs (RFC 4343 case-insensitive) - `ensure_cert()` — combines renewal check + provisioning in one call - Security: binary resolved to absolute path via `shutil.which()` to prevent PATH hijacking; `_SUBPROCESS_ENV` allowlist strips JWT keys, DB URLs, and SMTP passwords from subprocess environment; `asyncio.wait_for` timeouts (5 s for status, 60 s for cert provisioning) ### Changes to `CertificateService` (`certificate.py`) - New `ts_cert_path` / `ts_key_path` properties pointing to per-VP Tailscale cert files - New `use_tailscale_cert(fqdn, tailscale_svc)` method ### Changes to `VirtualPrinterInstance` (`manager.py`) - New `_resolve_cert_and_advertise()` — proxy mode always uses self-signed; server mode tries Tailscale then falls back - New `_cert_renewal_loop()` — daily background task; schedules restart via `_cert_restart_task` on renewal - New `_cancel_renewal_task()` / `_cancel_restart_task()` helpers with proper exception logging - `_restart_for_cert_renewal()` re-spawns the loop if restart fails ### New API endpoint `GET /api/v1/virtual-printers/tailscale-status` — returns `TailscaleStatusResponse`; protected by `RequirePermissionIfAuthEnabled(SETTINGS_READ)` ### Frontend (separate PR #1070) - Tailscale status card in `VirtualPrinterList` - `ShieldCheck` badge in `VirtualPrinterCard` when active - Per-VP `use_tailscale_cert` toggle in settings --- ## Fallback behaviour | Scenario | Behaviour | |----------|-----------| | `tailscale` binary not found | `get_status()` returns `available=False`; self-signed cert used | | Daemon stopped / not logged in | `tailscale status` exits non-zero; self-signed cert used | | HTTPS certs not enabled for tailnet | stderr parsed for disabled-certs pattern; actionable log points to admin DNS page; self-signed cert used | | Rate-limited by Let's Encrypt | Warning logged with exit code + stderr; self-signed cert used | | `tailscale cert` times out | `TimeoutError` caught; self-signed cert used | | Cert files not readable (root-owned) | `os.access()` check after write; actionable `sudo chown` log; self-signed cert used | | Any other exception | Caught at manager level; warning logged; self-signed cert used | | Docker socket not mounted | Info log with volume-mount instruction | --- ## Cert auto-renewal - Renewal check runs once per day while the VP is active - Renews when fewer than 30 days remain or FQDN no longer matches cert SANs - After successful renewal, the VP is restarted automatically — no manual intervention required - On error the loop backs off 1 hour; `CancelledError` on VP stop exits cleanly - Restart task tracked and cancelled cleanly if VP is deleted mid-restart --- ## Test plan - [x] `pytest backend/tests/unit/services/test_tailscale.py -v` — 43 tests, all pass - [x] `pytest backend/tests/unit/ -v` — no regressions - [x] Frontend: `npm run build` — clean, no TypeScript errors - [ ] Manual: start VP with Tailscale active → logs `"[VP …] Using Tailscale cert for <fqdn>"` - [ ] Manual: `GET /api/v1/virtual-printers/tailscale-status` returns FQDN and `available: true` - [ ] Manual: Tailscale disconnected → logs `"Tailscale not available, using self-signed cert"` - [ ] Manual: LAN slicer + toggle Off → discovery works without Tailscale access - [ ] Manual: Docker without socket mount → actionable log appears Closes #701 --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
BreizhHardware 2026-05-06 12:35:22 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/bambuddy#1150
No description provided.