Files
pulse/scripts/tests/test_pbs_docs.py
T
pulse-triage[bot] ff3d05be49 Set an explicit TLS floor for the synthetic PBS guide server
CodeQL on PRs 2361 and 2362 flags the fixture's implicit protocol floor. Require TLS 1.2 explicitly and test that same context; retain trusted, untrusted and wrong-host certificate controls without changing product TLS or documentation.

Contract-Neutral: Synthetic TLS test fixture only; no runtime or subsystem contract delta.
Change-source: pulse-maintainer
2026-10-01 09:09:01 +01:00

295 lines
14 KiB
Python

#!/usr/bin/env python3
"""Execute the PBS guide's recipes with synthetic tokens and guest-local TLS.
No real PBS/Pulse instance, installer, service or credential is used. Run the
loopback checks with pulse-worker-source-proof, not on the worker host.
"""
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import os
from pathlib import Path
import re
import shutil
import ssl
import stat
import subprocess
import tempfile
import threading
import unittest
ROOT = Path(__file__).resolve().parents[2]
DOC = ROOT / "docs/PBS.md"
PBS_TOKEN = "synthetic-pbs-secret"
AGENT_TOKEN = "synthetic-agent-secret"
AUTH_HEADER = f"PBSAPIToken=pulse-monitor@pbs!pulse-token:{PBS_TOKEN}"
def blocks():
# Nested list-item fences have two leading spaces, which are not shell
# input when copied from the rendered guide.
text = re.sub(r"^ ", "", DOC.read_text(), flags=re.MULTILINE)
return re.findall(r"```bash\n(.*?)```", text, re.DOTALL)
def recipe(needle):
matches = [block for block in blocks() if needle in block]
if len(matches) != 1:
raise AssertionError(f"expected one recipe containing {needle!r}")
return matches[0]
def fixture_environment(home):
env = dict(os.environ, HOME=str(home))
# Keep all requests guest-local even if the proof runtime has proxy vars.
for key in list(env):
if key.lower().endswith("_proxy"):
del env[key]
return env
def certificate(directory, name):
certificate = directory / f"{name}.pem"
key = directory / f"{name}.key"
subprocess.run([
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", str(key), "-out", str(certificate), "-days", "1",
"-subj", "/CN=localhost", "-addext", "subjectAltName=DNS:localhost",
], check=True, capture_output=True)
key.chmod(0o600)
return certificate, key
def server_context(cert, key):
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.load_cert_chain(str(cert), str(key))
return context
@contextmanager
def server(cert, key, status=200, installer=None, installer_path="/install.sh"):
requests = []
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
requests.append((self.path, dict(self.headers)))
self.send_response(status)
self.end_headers()
body = installer if self.path == installer_path else b'{"data":[]}\n'
self.wfile.write(body or b"fixture error\n")
def log_message(self, *_args):
pass
http = HTTPServer(("127.0.0.1", 0), Handler)
context = server_context(cert, key)
http.socket = context.wrap_socket(http.socket, server_side=True)
thread = threading.Thread(target=http.serve_forever, daemon=True)
thread.start()
try:
yield http.server_address[1], requests
finally:
http.shutdown()
http.server_close()
thread.join(timeout=5)
class PBSDocsTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temporary = tempfile.TemporaryDirectory()
cls.cert, cls.key = certificate(Path(cls.temporary.name), "server")
cls.other_cert, _ = certificate(Path(cls.temporary.name), "other")
@classmethod
def tearDownClass(cls):
cls.temporary.cleanup()
def test_tls_fixture_explicitly_rejects_legacy_protocols(self):
self.assertEqual(server_context(self.cert, self.key).minimum_version,
ssl.TLSVersion.TLSv1_2)
def test_shipped_copy_and_safe_scope(self):
self.assertEqual(DOC.read_bytes(), (ROOT / "frontend-modern/public/docs/PBS.md").read_bytes())
text = DOC.read_text()
shell = "\n".join(blocks())
self.assertNotRegex(shell, r"Authorization:|PBSAPIToken=|PULSE_SETUP_TOKEN=|--token(?:\s|=)|--insecure|curl\s+-[^\s]*k")
self.assertNotIn("/api2/json/version", shell)
self.assertNotIn("disable SSL verification", text)
for phrase in ("both the user and the token", "Manual Token Setup",
"not the **PBS** token", "independently verified",
"An agent is not", "empty list is not proof",
"Do not delete backups", "Do not clear History"):
self.assertIn(phrase, text)
def test_every_documented_shell_recipe_parses(self):
for command in blocks():
with self.subTest(command=command):
subprocess.run(["bash", "-n", "-c", command], check=True, capture_output=True)
def test_new_setup_grants_audit_to_user_and_token(self):
command = recipe("proxmox-backup-manager user create")
with tempfile.TemporaryDirectory() as temporary:
home = Path(temporary)
tool = home / "proxmox-backup-manager"
tool.write_text(
"#!/usr/bin/env python3\nimport json, os, sys\n"
"with open(os.environ['CALLS'], 'a') as f: f.write(json.dumps(sys.argv[1:]) + '\\n')\n"
)
tool.chmod(0o700)
env = fixture_environment(home)
env.update(PATH=f"{home}:{os.environ['PATH']}", CALLS=str(home / "calls.jsonl"))
subprocess.run(["bash", "-eu", "-c", command], env=env, check=True)
calls = [json.loads(line) for line in (home / "calls.jsonl").read_text().splitlines()]
self.assertEqual(calls, [
["user", "create", "pulse-monitor@pbs", "--comment", "Pulse monitoring"],
["user", "generate-token", "pulse-monitor@pbs", "pulse-token"],
["acl", "update", "/", "Audit", "--auth-id", "pulse-monitor@pbs"],
["acl", "update", "/", "Audit", "--auth-id", "pulse-monitor@pbs!pulse-token"],
])
# Same permission shapes as the owning implementation, not a claim of
# running proxmox-backup-manager on a real appliance.
source = (ROOT / "internal/api/configapi/setup_script_render.go").read_text()
self.assertIn("proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs", source)
self.assertIn('proxmox-backup-manager acl update / Audit --auth-id "$PULSE_TOKEN_ID"', source)
def test_private_preparation_preserves_existing_content(self):
for name in ("pbs-agent-token", "pbs-header"):
command = recipe(f'vi "$HOME/.config/pulse/{name}"')
with self.subTest(name=name), tempfile.TemporaryDirectory() as temporary:
home = Path(temporary)
tools = home / "tools"
tools.mkdir()
editor = tools / "vi"
editor.write_text('#!/bin/sh\n[ "$#" = 1 ] && [ -f "$1" ]\n')
editor.chmod(0o700)
env = fixture_environment(home)
env["PATH"] = f"{tools}:{os.environ['PATH']}"
path = home / ".config/pulse" / name
for existing in (False, True):
if existing:
path.write_text("synthetic-existing-content\n")
path.chmod(0o644)
path.parent.chmod(0o755)
subprocess.run(["bash", "-eu", "-c", command], env=env, check=True)
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
self.assertEqual(stat.S_IMODE(path.parent.stat().st_mode), 0o700)
self.assertEqual(path.read_text(), "synthetic-existing-content\n" if existing else "")
def run_curl(self, home, command, port, ca=None, hostname="localhost"):
private = home / ".config/pulse"
private.mkdir(parents=True, exist_ok=True, mode=0o700)
header = private / "pbs-header"
header.write_text(f"Authorization: {AUTH_HEADER}\n")
header.chmod(0o600)
real_curl = shutil.which("curl")
self.assertIsNotNone(real_curl)
tools = home / "tools"
tools.mkdir(exist_ok=True)
recorder = tools / "curl"
recorder.write_text(
"#!/usr/bin/env python3\nimport json, os, sys\nfrom pathlib import Path\n"
"Path(os.environ['ARGV_RECEIPT']).write_text(json.dumps(sys.argv[1:]))\n"
"os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *sys.argv[1:]])\n"
)
recorder.chmod(0o700)
receipt = home / "argv.json"
env = fixture_environment(home)
env.update(PATH=f"{tools}:{os.environ['PATH']}", REAL_CURL=real_curl, ARGV_RECEIPT=str(receipt))
command = command.replace("https://pbs.example.com:8007", f"https://{hostname}:{port}")
command = command.replace("https://pulse.example.com", f"https://{hostname}:{port}")
if ca is not None:
shutil.copyfile(ca, private / "pbs-ca.pem")
# This is the guide's optional, independently verified CA-file form.
command = command.replace("curl ", 'curl --cacert "$HOME/.config/pulse/pbs-ca.pem" ', 1)
result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True, timeout=20)
argv = json.loads(receipt.read_text())
for secret in (PBS_TOKEN, AGENT_TOKEN):
self.assertNotIn(secret, " ".join(argv))
self.assertNotIn(secret.encode(), result.stdout + result.stderr)
self.assertNotIn("--insecure", argv)
self.assertNotIn("-k", argv)
return result, argv
def test_datastore_request_transmits_auth_only_in_header(self):
with tempfile.TemporaryDirectory() as temporary, server(self.cert, self.key) as (port, requests):
result, argv = self.run_curl(Path(temporary), recipe("--fail-with-body"), port, self.cert)
self.assertEqual(result.returncode, 0, result.stderr.decode())
self.assertIn("--max-time", argv)
self.assertIn("--connect-timeout", argv)
self.assertIn("@" + str(Path(temporary) / ".config/pulse/pbs-header"), argv)
self.assertEqual(requests, [("/api2/json/admin/datastore", {
**requests[0][1], "Authorization": AUTH_HEADER,
})])
self.assertEqual(result.stdout, b'{"data":[]}\n')
def test_auth_errors_retain_body_and_fail(self):
for status in (401, 403):
with self.subTest(status=status), tempfile.TemporaryDirectory() as temporary:
with server(self.cert, self.key, status) as (port, requests):
result, _ = self.run_curl(Path(temporary), recipe("--fail-with-body"), port, self.cert)
self.assertEqual(result.returncode, 22, result.stderr.decode())
self.assertEqual(len(requests), 1)
self.assertEqual(result.stdout, b'{"data":[]}\n')
def test_tls_rejects_untrusted_wrong_certificate_and_wrong_hostname(self):
for ca, hostname in ((None, "localhost"), (self.other_cert, "localhost"), (self.cert, "127.0.0.1")):
with self.subTest(ca=ca, hostname=hostname), tempfile.TemporaryDirectory() as temporary:
with server(self.cert, self.key) as (port, requests):
result, _ = self.run_curl(Path(temporary), recipe("--fail-with-body"), port, ca, hostname)
self.assertEqual(result.returncode, 60, result.stderr.decode())
# Authentication is never sent over an unverified channel.
self.assertEqual(requests, [])
def test_download_and_agent_file_handoff(self):
# The downloaded fixture deliberately cannot install or change a
# service. It verifies only the documented argument/file handoff.
installer = b'''#!/bin/bash
python3 - "$@" <<'PY'
import json, os, stat, sys
from pathlib import Path
args = sys.argv[1:]
path = Path(args[args.index('--token-file') + 1])
assert stat.S_IMODE(path.stat().st_mode) == 0o600
assert path.read_text().strip() == os.environ['EXPECTED_AGENT_TOKEN']
assert os.environ['EXPECTED_AGENT_TOKEN'] not in ' '.join(args)
Path(os.environ['AGENT_RECEIPT']).write_text(json.dumps(args))
PY
'''
with tempfile.TemporaryDirectory() as temporary, server(self.cert, self.key, installer=installer) as (port, requests):
home = Path(temporary)
result, _ = self.run_curl(home, recipe('--output "$HOME/.config/pulse/pbs-agent-install.sh"'), port, self.cert)
self.assertEqual(result.returncode, 0, result.stderr.decode())
self.assertEqual(requests[0][0], "/install.sh")
self.assertNotIn("Authorization", requests[0][1])
token = home / ".config/pulse/pbs-agent-token"
token.write_text(AGENT_TOKEN + "\n")
token.chmod(0o600)
command = recipe('bash "$HOME/.config/pulse/pbs-agent-install.sh"')
env = fixture_environment(home)
env.update(EXPECTED_AGENT_TOKEN=AGENT_TOKEN, AGENT_RECEIPT=str(home / "agent-argv.json"))
result = subprocess.run(["bash", "-eu", "-c", command], env=env, capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr.decode())
self.assertEqual(json.loads((home / "agent-argv.json").read_text()), [
"--url", "https://pulse.example.com", "--token-file", str(token),
"--enable-proxmox", "--proxmox-type", "pbs", "--enable-docker=false",
])
self.assertNotIn(AGENT_TOKEN.encode(), result.stdout + result.stderr)
actual = (ROOT / "scripts/install.sh").read_text()
self.assertIn('--token-file) TOKEN_FILE_PATH="$2"; shift 2 ;;', actual)
self.assertIn('read_collector_token_file_safely "$TOKEN_FILE_PATH" true', actual)
def test_failed_installer_download_is_nonzero(self):
with tempfile.TemporaryDirectory() as temporary, server(self.cert, self.key, 403) as (port, _requests):
home = Path(temporary)
result, _ = self.run_curl(home, recipe('--output "$HOME/.config/pulse/pbs-agent-install.sh"'), port, self.cert)
self.assertEqual(result.returncode, 22, result.stderr.decode())
self.assertFalse((home / ".config/pulse/pbs-agent-install.sh").exists())
if __name__ == "__main__":
unittest.main()