mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Add the Tailscale CLI to the production image and document how to
enable Let's Encrypt cert provisioning for virtual printers from a
Docker-deployed Bambuddy.
- Dockerfile installs `tailscale` from the official Debian repo. Only
the CLI is used at runtime; tailscaled itself stays on the host.
The binary is harmless if the socket isn't mounted — the code logs
an actionable hint and falls back to self-signed certs.
- docker-compose.yml adds a commented-out volume mount for
/var/run/tailscale/tailscaled.sock with inline setup instructions.
- tailscale.py's docker-socket hint now also fires when the binary is
present but the daemon socket is unreachable (i.e. the new Docker
pattern), not just when the binary is missing, so users get the
actionable "mount the socket" message instead of opaque CLI stderr.
Enabling the integration on a Docker host:
1. `curl -fsSL https://tailscale.com/install.sh | sh` on host
2. `sudo tailscale up`
3. `sudo tailscale set --operator=<user>` for the container PUID
4. Uncomment the tailscaled.sock mount in docker-compose.yml
5. `docker compose up -d --force-recreate`
6. Flip the Tailscale toggle on the VP card
339 lines
12 KiB
Python
339 lines
12 KiB
Python
"""Tailscale integration for virtual printer certificate provisioning.
|
|
|
|
When Tailscale is present, provisions a Let's Encrypt certificate via
|
|
`tailscale cert` for the machine's Tailscale FQDN. This cert is trusted
|
|
by slicers without any manual CA installation, unlike the self-signed CA.
|
|
|
|
Falls back gracefully when Tailscale is unavailable.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from cryptography import x509
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Renew when fewer than this many days remain on the LE cert (LE issues 90-day certs;
|
|
# Let's Encrypt recommends renewing at 30 days remaining)
|
|
TS_CERT_EXPIRY_THRESHOLD_DAYS = 30
|
|
|
|
# Defensive FQDN validation before passing to subprocess
|
|
_FQDN_RE = re.compile(
|
|
r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Detect tailnets where HTTPS cert generation is disabled — common for company/school
|
|
# tailnets where the user is not a Tailscale admin.
|
|
_HTTPS_DISABLED_RE = re.compile(
|
|
r"(https? cert.*disabled|not enabled.*tailnet|cert.*not.*enabled)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Minimal environment for tailscale subprocess — passes OS/shell variables that
|
|
# tailscale needs to locate its socket and config, but strips application secrets
|
|
# (JWT keys, DB URLs, SMTP passwords, etc.) that the subprocess has no need for.
|
|
_SUBPROCESS_ENV: dict[str, str] = {
|
|
k: v
|
|
for k, v in os.environ.items()
|
|
if k
|
|
in {
|
|
"PATH",
|
|
"HOME",
|
|
"USER",
|
|
"USERNAME",
|
|
"LOGNAME",
|
|
# Windows equivalents
|
|
"USERPROFILE",
|
|
"APPDATA",
|
|
"LOCALAPPDATA",
|
|
"PROGRAMFILES",
|
|
"PROGRAMFILES(X86)",
|
|
"SYSTEMROOT",
|
|
"WINDIR",
|
|
"COMPUTERNAME",
|
|
"TEMP",
|
|
"TMP",
|
|
# Linux XDG dirs used by tailscale for socket/config
|
|
"XDG_RUNTIME_DIR",
|
|
"XDG_CONFIG_HOME",
|
|
}
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class TailscaleStatus:
|
|
"""Runtime Tailscale availability and identity."""
|
|
|
|
available: bool
|
|
hostname: str # "myhost"
|
|
tailnet_name: str # "tailnetname.ts.net"
|
|
fqdn: str # "myhost.tailnetname.ts.net"
|
|
tailscale_ips: list[str] = field(default_factory=list)
|
|
error: str | None = None
|
|
|
|
|
|
class TailscaleService:
|
|
"""Wraps Tailscale CLI commands for certificate provisioning.
|
|
|
|
All methods are safe to call when Tailscale is absent — they return
|
|
sensible defaults and never raise exceptions.
|
|
"""
|
|
|
|
_docker_hint_logged: bool = False
|
|
|
|
@classmethod
|
|
def _log_docker_socket_hint(cls) -> None:
|
|
"""Log a one-time hint when running in Docker without the Tailscale socket mounted.
|
|
|
|
Fires in both states: (a) tailscale binary missing and (b) binary present
|
|
but the host socket isn't mounted into the container. The binary alone
|
|
can't talk to the daemon — the host's tailscaled socket needs to be
|
|
volume-mounted in docker-compose.yml.
|
|
"""
|
|
if cls._docker_hint_logged:
|
|
return
|
|
if Path("/.dockerenv").exists() and not Path("/var/run/tailscale/tailscaled.sock").exists():
|
|
logger.info(
|
|
"Running in Docker but /var/run/tailscale/tailscaled.sock is not mounted. "
|
|
"Add `- /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock` "
|
|
"to docker-compose.yml (under volumes:) and run Tailscale on the host to enable "
|
|
"Let's Encrypt certs for virtual printers."
|
|
)
|
|
cls._docker_hint_logged = True
|
|
|
|
async def _run_tailscale(self, *args: str, timeout: float = 30.0) -> tuple[int | None, bytes, bytes]:
|
|
"""Run a tailscale subcommand and return (returncode, stdout, stderr).
|
|
|
|
Resolves the binary to an absolute path to guard against PATH hijacking.
|
|
Raises OSError if the binary cannot be found or launched.
|
|
Raises asyncio.TimeoutError if the subprocess exceeds the timeout.
|
|
"""
|
|
binary = shutil.which("tailscale")
|
|
if not binary:
|
|
raise OSError("tailscale binary not found")
|
|
process = await asyncio.create_subprocess_exec(
|
|
binary,
|
|
*args,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=_SUBPROCESS_ENV,
|
|
)
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
process.kill()
|
|
await process.wait()
|
|
raise
|
|
return process.returncode, stdout, stderr
|
|
|
|
async def get_status(self) -> TailscaleStatus:
|
|
"""Query Tailscale status and return machine identity.
|
|
|
|
Runs: tailscale status --json
|
|
|
|
Returns TailscaleStatus(available=False) if the binary is missing,
|
|
the daemon is not running, or any other error occurs.
|
|
"""
|
|
if not shutil.which("tailscale"):
|
|
self._log_docker_socket_hint()
|
|
return TailscaleStatus(
|
|
available=False,
|
|
hostname="",
|
|
tailnet_name="",
|
|
fqdn="",
|
|
error="tailscale binary not found",
|
|
)
|
|
|
|
try:
|
|
returncode, stdout, stderr = await self._run_tailscale("status", "--json", timeout=5.0)
|
|
except OSError as e:
|
|
return TailscaleStatus(
|
|
available=False,
|
|
hostname="",
|
|
tailnet_name="",
|
|
fqdn="",
|
|
error=str(e),
|
|
)
|
|
|
|
if returncode is None or returncode != 0:
|
|
# If the binary is present but the daemon socket is unreachable (e.g.
|
|
# Docker without the socket mount), log the actionable hint rather than
|
|
# just the opaque CLI stderr.
|
|
self._log_docker_socket_hint()
|
|
return TailscaleStatus(
|
|
available=False,
|
|
hostname="",
|
|
tailnet_name="",
|
|
fqdn="",
|
|
error=stderr.decode(errors="replace").strip(),
|
|
)
|
|
|
|
try:
|
|
data = json.loads(stdout)
|
|
except json.JSONDecodeError as e:
|
|
return TailscaleStatus(
|
|
available=False,
|
|
hostname="",
|
|
tailnet_name="",
|
|
fqdn="",
|
|
error=f"JSON parse error: {e}",
|
|
)
|
|
|
|
self_info = data.get("Self", {})
|
|
|
|
# DNSName includes trailing dot: "myhost.tailnetname.ts.net."
|
|
fqdn = self_info.get("DNSName", "").rstrip(".")
|
|
if not fqdn:
|
|
return TailscaleStatus(
|
|
available=False,
|
|
hostname="",
|
|
tailnet_name="",
|
|
fqdn="",
|
|
error="Tailscale not connected (no DNSName)",
|
|
)
|
|
|
|
# Split "myhost.tailnetname.ts.net" into hostname + tailnet_name
|
|
parts = fqdn.split(".", 1)
|
|
hostname = parts[0]
|
|
tailnet_name = parts[1] if len(parts) > 1 else ""
|
|
|
|
tailscale_ips = self_info.get("TailscaleIPs", [])
|
|
|
|
logger.debug("Tailscale available: fqdn=%s, ips=%s", fqdn, tailscale_ips)
|
|
return TailscaleStatus(
|
|
available=True,
|
|
hostname=hostname,
|
|
tailnet_name=tailnet_name,
|
|
fqdn=fqdn,
|
|
tailscale_ips=tailscale_ips,
|
|
)
|
|
|
|
async def provision_cert(self, fqdn: str, cert_path: Path, key_path: Path) -> bool:
|
|
"""Request a Let's Encrypt certificate for the given Tailscale FQDN.
|
|
|
|
Runs: tailscale cert --cert-file <cert_path> --key-file <key_path> <fqdn>
|
|
|
|
Returns True on success, False on any error.
|
|
"""
|
|
if not _FQDN_RE.match(fqdn):
|
|
logger.warning("provision_cert: invalid FQDN %r, skipping", fqdn)
|
|
return False
|
|
|
|
# Ensure the target directory exists before tailscale cert writes to it
|
|
cert_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
logger.info("Provisioning Tailscale cert for %s -> %s", fqdn, cert_path)
|
|
try:
|
|
returncode, _, stderr = await self._run_tailscale(
|
|
"cert",
|
|
"--cert-file",
|
|
str(cert_path),
|
|
"--key-file",
|
|
str(key_path),
|
|
fqdn,
|
|
timeout=60.0,
|
|
)
|
|
except OSError as e:
|
|
logger.warning("tailscale cert failed (OS error): %s", e)
|
|
return False
|
|
|
|
if returncode is None or returncode != 0:
|
|
err_text = stderr.decode(errors="replace").strip()
|
|
if _HTTPS_DISABLED_RE.search(err_text):
|
|
logger.warning(
|
|
"Tailscale HTTPS certs are not enabled for this tailnet. "
|
|
"Visit https://login.tailscale.com/admin/dns and enable HTTPS. "
|
|
"Falling back to self-signed cert."
|
|
)
|
|
else:
|
|
logger.warning("tailscale cert failed (exit %s): %s", returncode, err_text)
|
|
return False
|
|
|
|
# Restrict private key permissions
|
|
try:
|
|
key_path.chmod(0o600)
|
|
except OSError as e:
|
|
logger.warning("Could not set key permissions on %s: %s", key_path, e)
|
|
|
|
# Verify the files are readable by the current process — on bare-metal, the
|
|
# tailscale daemon or a prior sudo invocation may have left them root-owned.
|
|
if not os.access(cert_path, os.R_OK) or not os.access(key_path, os.R_OK):
|
|
logger.error(
|
|
"Tailscale cert files at %s are not readable by this process. "
|
|
"Fix with: sudo chown $(whoami):$(whoami) %s %s",
|
|
cert_path.parent,
|
|
cert_path,
|
|
key_path,
|
|
)
|
|
return False
|
|
|
|
logger.info("Tailscale cert provisioned: %s", cert_path)
|
|
return True
|
|
|
|
def cert_needs_renewal(self, cert_path: Path, fqdn: str | None = None) -> bool:
|
|
"""Check whether the certificate at cert_path needs to be renewed.
|
|
|
|
Returns True if the file is absent, unreadable, expires within
|
|
TS_CERT_EXPIRY_THRESHOLD_DAYS days, or if fqdn is given and does not
|
|
appear in the certificate's Subject Alternative Names.
|
|
"""
|
|
if not cert_path.exists():
|
|
return True
|
|
|
|
try:
|
|
cert_pem = cert_path.read_bytes()
|
|
# The file may contain a full chain; load only the first PEM block
|
|
cert = x509.load_pem_x509_certificate(cert_pem)
|
|
now = datetime.now(timezone.utc)
|
|
days_remaining = (cert.not_valid_after_utc - now).days
|
|
if days_remaining < TS_CERT_EXPIRY_THRESHOLD_DAYS:
|
|
logger.info("Tailscale cert expires in %d days, renewal needed", days_remaining)
|
|
return True
|
|
|
|
# Validate that the cert covers the requested FQDN (guards against stale
|
|
# cert after machine rename or tailnet migration). Case-insensitive per RFC 4343.
|
|
if fqdn:
|
|
try:
|
|
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
|
dns_names = san.value.get_values_for_type(x509.DNSName)
|
|
if fqdn.lower() not in {n.lower() for n in dns_names}:
|
|
logger.info(
|
|
"Tailscale cert SAN mismatch (cert has %s, need %s), renewal needed",
|
|
dns_names,
|
|
fqdn,
|
|
)
|
|
return True
|
|
except x509.ExtensionNotFound:
|
|
logger.info("Tailscale cert has no SAN extension, renewal needed")
|
|
return True
|
|
|
|
logger.debug("Tailscale cert valid for %d more days", days_remaining)
|
|
return False
|
|
except (OSError, ValueError) as e:
|
|
logger.warning("Could not read Tailscale cert %s: %s", cert_path, e)
|
|
return True
|
|
|
|
async def ensure_cert(self, fqdn: str, cert_path: Path, key_path: Path) -> bool:
|
|
"""Ensure a fresh certificate exists at cert_path.
|
|
|
|
Skips provisioning if the cert is present, not near expiry, and covers fqdn.
|
|
Returns True if a valid cert is now available.
|
|
"""
|
|
if not self.cert_needs_renewal(cert_path, fqdn=fqdn):
|
|
logger.debug("Tailscale cert is fresh, skipping provision")
|
|
return True
|
|
return await self.provision_cert(fqdn, cert_path, key_path)
|
|
|
|
|
|
# Module-level singleton — import this in other modules
|
|
tailscale_service = TailscaleService()
|