diff --git a/.github/workflows/public-docs.yml b/.github/workflows/public-docs.yml index 792f371c5..e97be31df 100644 --- a/.github/workflows/public-docs.yml +++ b/.github/workflows/public-docs.yml @@ -9,6 +9,7 @@ on: - ".github/ISSUE_TEMPLATE/**" - "scripts/check_public_docs.py" - "scripts/check_docs_mirror.py" + - "scripts/readme_entrypoints_test.py" - ".github/workflows/public-docs.yml" push: branches: @@ -20,6 +21,7 @@ on: - ".github/ISSUE_TEMPLATE/**" - "scripts/check_public_docs.py" - "scripts/check_docs_mirror.py" + - "scripts/readme_entrypoints_test.py" - ".github/workflows/public-docs.yml" permissions: @@ -42,5 +44,8 @@ jobs: - name: Validate public documentation run: python3 scripts/check_public_docs.py + - name: Check getting-started safety and current plans + run: python3 scripts/readme_entrypoints_test.py + - name: Check shipped docs mirror sync run: python3 scripts/check_docs_mirror.py diff --git a/README.md b/README.md index ee16f5002..0b8ecdead 100644 --- a/README.md +++ b/README.md @@ -91,17 +91,19 @@ The installer is signed. Verify `install.sh` against the pinned ```bash export PULSE_VERSION=vX.Y.Z -curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh" -curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh.sshsig" +curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh" && +curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh.sshsig" && ssh-keygen -Y verify \ -f <(printf '%s\n' 'pulse-installer namespaces="pulse-install" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer') \ -I pulse-installer \ -n pulse-install \ - -s install.sh.sshsig < install.sh + -s install.sh.sshsig < install.sh && bash install.sh --version "${PULSE_VERSION}" -rm -f install.sh install.sh.sshsig ``` +Paste the whole block: installation stops if either download or signature +verification fails. Do not run the final command separately to bypass a failure. + The GitHub installer installs the Pulse server. Install and upgrade agents (including v5-to-v6 agent upgrades) with the per-host command generated under **Settings → Infrastructure → Install on a host**. @@ -133,15 +135,19 @@ agent can and cannot do at each privilege level. - **Community** — self-hosted monitoring, seven days of metric history, core SSO, update alerts, and Patrol with your own provider or local model. -- **Relay** — Community plus secure remote web access, Pulse Mobile pairing, - push notifications, and fourteen days of history. -- **Pro** — Relay plus Patrol investigation, governed fixes, ninety days of +- **Pro** — Community plus Patrol investigation, governed fixes, ninety days of history, centralized agent profiles, RBAC, audit logging, and reporting. - **MSP** — for managed service providers: one Pulse Account running many client workspaces, each with an isolated Pulse runtime — separate dashboards, alerts, users, audit history, and reports. Free sixty-day two-client evaluation at [Pulse for MSPs](https://pulserelay.pro/msp). +Relay is no longer sold. Existing Relay subscribers receive Pro features at their +current price for as long as their subscription continues. Existing paired phones +keep working until **31 March 2027**. Relay connects the app, not the web UI. Use +your own VPN or tunnel for remote web access. For phone alerts, add an +ntfy, Gotify or Pushover destination and open Pulse in your phone's browser. + Core self-hosted monitoring is not gated by monitored-system or child-resource volume. See the [runtime-aligned capability reference](docs/PULSE_PRO.md) and [current plans](https://pulserelay.pro) for details. diff --git a/docs/i18n/de/README.md b/docs/i18n/de/README.md index 64800c2eb..12d0d1110 100644 --- a/docs/i18n/de/README.md +++ b/docs/i18n/de/README.md @@ -10,10 +10,17 @@ absichtlich unverändert. Pulse ist ein self-hosted Monitoring-Arbeitsbereich für Proxmox, Docker, Kubernetes, TrueNAS und verwandte Infrastruktur. Community deckt das -Kernmonitoring kostenlos ab. Relay ergänzt sicheren Remote-Zugriff auf die -Pulse-Weboberfläche, Pulse-Mobile-Handoff-Pairing, Push-Benachrichtigungen und -14 Tage Verlauf. Pro ergänzt Ursachenanalyse, sichere Remediation-Workflows, -Operations-Werkzeuge, Governance-Funktionen und 90 Tage Verlauf. +Kernmonitoring kostenlos ab. Pro ergänzt Ursachenanalyse, sichere +Remediation-Workflows, Operations-Werkzeuge, Governance-Funktionen und 90 Tage +Verlauf. + +Relay wird nicht mehr verkauft. Bestehende Relay-Abonnenten erhalten +Pro-Funktionen zum bisherigen Preis, solange ihr Abonnement weiterläuft. +Bereits gekoppelte Telefone funktionieren bis zum **31. März 2027** weiter. +Relay verbindet Pulse mit der App, nicht mit der Weboberfläche. Für den +Remote-Zugriff auf die Weboberfläche nutze ein eigenes VPN oder einen Tunnel. +Für Warnmeldungen auf dem Telefon füge ein Ziel für ntfy, Gotify oder Pushover +hinzu und öffne Pulse im Browser deines Telefons. ## Bezahlte Relay-, Pro- und Legacy-Kunden @@ -37,17 +44,20 @@ Installer-Datei und führe den Installer auf deinem Proxmox-Host aus: ```bash export PULSE_VERSION=vX.Y.Z -curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh" -curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh.sshsig" +curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh" && +curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh.sshsig" && ssh-keygen -Y verify \ -f <(printf '%s\n' 'pulse-installer namespaces="pulse-install" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer') \ -I pulse-installer \ -n pulse-install \ - -s install.sh.sshsig < install.sh + -s install.sh.sshsig < install.sh && bash install.sh --version "${PULSE_VERSION}" -rm -f install.sh install.sh.sshsig ``` +Füge den gesamten Block ein: Schlägt ein Download oder die Signaturprüfung fehl, +wird der Installer nicht ausgeführt. Führe den letzten Befehl nicht separat aus, +um einen Fehler zu umgehen. + Agent-Installationen und v5-zu-v6-Agent-Upgrades verwenden den Befehl, den Pulse unter **Settings → Infrastructure → Install on a host** erzeugt. Dieser Befehl wird von deinem Pulse-Server über `/install.sh` bereitgestellt. diff --git a/docs/i18n/es/README.md b/docs/i18n/es/README.md index 63fa3e830..922051c1f 100644 --- a/docs/i18n/es/README.md +++ b/docs/i18n/es/README.md @@ -10,11 +10,16 @@ la interfaz se conservan sin traducir de forma intencional. Pulse es un workspace de monitoreo autohospedado para Proxmox, Docker, Kubernetes, TrueNAS e infraestructura relacionada. Community incluye el -monitoreo principal gratis. Relay añade acceso remoto seguro a la interfaz web -de Pulse, emparejamiento con Pulse Mobile para handoff, notificaciones push e -historial de 14 días. Pro añade análisis de causa raíz, flujos de remediación -seguros, herramientas operativas, funciones de gobernanza e historial de 90 -días. +monitoreo principal gratis. Pro añade análisis de causa raíz, flujos de +remediación seguros, herramientas operativas, funciones de gobernanza e +historial de 90 días. + +Relay ya no se vende. Los suscriptores actuales de Relay reciben funciones Pro +al precio actual mientras continúe su suscripción. Los teléfonos ya emparejados +siguen funcionando hasta el **31 de marzo de 2027**. Relay conecta la app, +no la interfaz web. Para acceder a la interfaz web fuera de casa, usa tu propia +VPN o un túnel. Para alertas en el teléfono, añade un destino de ntfy, Gotify o +Pushover y abre Pulse en el navegador de tu teléfono. ## Clientes de pago Relay, Pro y legacy @@ -37,17 +42,20 @@ host Proxmox: ```bash export PULSE_VERSION=vX.Y.Z -curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh" -curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh.sshsig" +curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh" && +curl -fsSLO "https://github.com/rcourtman/Pulse/releases/download/${PULSE_VERSION}/install.sh.sshsig" && ssh-keygen -Y verify \ -f <(printf '%s\n' 'pulse-installer namespaces="pulse-install" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer') \ -I pulse-installer \ -n pulse-install \ - -s install.sh.sshsig < install.sh + -s install.sh.sshsig < install.sh && bash install.sh --version "${PULSE_VERSION}" -rm -f install.sh install.sh.sshsig ``` +Pega el bloque completo: si falla una descarga o la verificación de firma, el +instalador no se ejecuta. No ejecutes el último comando por separado para omitir +un fallo. + Las instalaciones de agentes y las actualizaciones de agentes de v5 a v6 usan el comando que Pulse genera en **Settings → Infrastructure → Install on a host**. Ese comando se sirve desde tu servidor Pulse en `/install.sh`. diff --git a/scripts/readme_entrypoints_test.py b/scripts/readme_entrypoints_test.py new file mode 100644 index 000000000..c34980bb1 --- /dev/null +++ b/scripts/readme_entrypoints_test.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Exercise the public landing pages' signed-install gate without network access.""" + +from __future__ import annotations + +import json +import re +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def read_repo_text(relative_path: str) -> str: + return (ROOT / relative_path).read_text(encoding="utf-8") + + +ENTRYPOINTS = ( + "README.md", + "docs/i18n/de/README.md", + "docs/i18n/es/README.md", +) +SIGNER = ( + 'pulse-installer namespaces="pulse-install" ssh-ed25519 ' + "AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer" +) + + +def installer_commands(text: str) -> str: + for block in re.findall(r"```bash\n(.*?)\n```", text, flags=re.DOTALL): + if "ssh-keygen -Y verify" not in block: + continue + lines = block.splitlines() + for index, line in enumerate(lines): + if line.startswith("bash install.sh --version "): + # Only exercise the download -> verification -> execution gate, + # never any subsequent file-cleanup recipe. + return "\n".join(lines[: index + 1]) + "\n" + raise AssertionError("signed server-installer command block is missing") + + +STUB = """#!/usr/bin/python3 +import json +import os +import sys +from pathlib import Path + +name = Path(sys.argv[0]).name +trace = Path(os.environ['TRACE']) +events = [json.loads(line) for line in trace.read_text().splitlines()] if trace.exists() else [] +event = {'command': name, 'argv': sys.argv[1:]} +if name == 'ssh-keygen': + event['allowed_signers'] = Path(sys.argv[sys.argv.index('-f') + 1]).read_text() +with trace.open('a') as out: + out.write(json.dumps(event) + '\\n') +if name == 'curl': + attempt = 1 + sum(event['command'] == 'curl' for event in events) + code = int(os.environ.get('CURL_' + str(attempt), '0')) + if code == 0: + # Public dummy bytes only. The installer itself is never executed. + Path(sys.argv[-1].rsplit('/', 1)[-1]).write_text('downloaded fixture\\n') +elif name == 'ssh-keygen': + sys.stdin.read() + code = int(os.environ.get('VERIFY', '0')) +elif name == 'bash': + code = int(os.environ.get('INSTALL', '0')) +else: + raise AssertionError('unexpected stub command') +sys.exit(code) +""" + + +def exercise_install(commands: str, exits: dict[str, str]) -> tuple[int, list[dict]]: + with tempfile.TemporaryDirectory(prefix="pulse-readme-gate-") as directory: + root = Path(directory) + stubs = root / "bin" + stubs.mkdir() + for name in ("curl", "ssh-keygen", "bash"): + stub = stubs / name + stub.write_text(STUB, encoding="utf-8") + stub.chmod(0o700) + # A failed download must not execute files left by an earlier attempt. + (root / "install.sh").write_text("stale installer fixture\n", encoding="utf-8") + (root / "install.sh.sshsig").write_text("stale signature fixture\n", encoding="utf-8") + trace = root / "trace.jsonl" + completed = subprocess.run( + ["/bin/bash", "-c", commands], + cwd=root, + env={"PATH": f"{stubs}:/usr/bin:/bin", "TRACE": str(trace), **exits}, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + if completed.stderr: + raise AssertionError(completed.stderr) + events = [json.loads(line) for line in trace.read_text().splitlines()] + return completed.returncode, events + + +class ReadmeEntrypointsTest(unittest.TestCase): + def test_signed_install_stops_at_each_failed_trust_step(self) -> None: + cases = ( + ({"CURL_1": "22"}, 22, ["curl"]), + ({"CURL_2": "23"}, 23, ["curl", "curl"]), + ({"VERIFY": "1"}, 1, ["curl", "curl", "ssh-keygen"]), + ) + for relative_path in ENTRYPOINTS: + commands = installer_commands(read_repo_text(relative_path)) + for exits, expected_exit, expected_steps in cases: + with self.subTest(page=relative_path, exits=exits): + code, events = exercise_install(commands, exits) + self.assertEqual([event["command"] for event in events], expected_steps) + self.assertEqual(code, expected_exit) + + def test_verified_install_keeps_pinned_version_and_signer(self) -> None: + for relative_path in ENTRYPOINTS: + with self.subTest(page=relative_path): + commands = installer_commands(read_repo_text(relative_path)) + self.assertIn(SIGNER, commands) + code, events = exercise_install(commands, {}) + self.assertEqual(code, 0) + self.assertEqual( + [event["command"] for event in events], + ["curl", "curl", "ssh-keygen", "bash"], + ) + self.assertEqual( + [event["argv"][-1] for event in events[:2]], + [ + "https://github.com/rcourtman/Pulse/releases/download/vX.Y.Z/install.sh", + "https://github.com/rcourtman/Pulse/releases/download/vX.Y.Z/install.sh.sshsig", + ], + ) + self.assertTrue(all(event["argv"][0] == "-fsSLO" for event in events[:2])) + self.assertEqual(events[2]["allowed_signers"], SIGNER + "\n") + verify = events[2]["argv"] + for option, value in ( + ("-I", "pulse-installer"), + ("-n", "pulse-install"), + ("-s", "install.sh.sshsig"), + ): + self.assertEqual(verify[verify.index(option) + 1], value) + self.assertEqual(events[3]["argv"], ["install.sh", "--version", "vX.Y.Z"]) + + def test_installer_failure_remains_nonzero(self) -> None: + for relative_path in ENTRYPOINTS: + with self.subTest(page=relative_path): + code, events = exercise_install( + installer_commands(read_repo_text(relative_path)), {"INSTALL": "17"} + ) + self.assertEqual(code, 17) + self.assertEqual(events[-1]["command"], "bash") + + def test_entrypoint_plans_do_not_offer_retired_relay(self) -> None: + expected = { + "README.md": ( + "31 March 2027", "Relay is no longer sold", "current price", + "for as long as their subscription continues", "own VPN or tunnel", + "ntfy, Gotify or Pushover", "not the web UI", + ), + "docs/i18n/de/README.md": ( + "31. März 2027", "Relay wird nicht mehr verkauft", "bisherigen Preis", + "solange ihr Abonnement weiterläuft", "eigenes VPN oder einen Tunnel", + "ntfy, Gotify oder Pushover", "nicht mit der Weboberfläche", + ), + "docs/i18n/es/README.md": ( + "31 de marzo de 2027", "Relay ya no se vende", "precio actual", + "mientras continúe su suscripción", "tu propia VPN o un túnel", + "ntfy, Gotify o Pushover", "no la interfaz web", + ), + } + stale_claims = ( + "**Relay** — Community plus", "**Pro** — Relay plus", + "Relay ergänzt sicheren Remote-Zugriff", "Relay añade acceso remoto seguro", + ) + for relative_path, fragments in expected.items(): + with self.subTest(page=relative_path): + prose = " ".join(read_repo_text(relative_path).split()) + for fragment in fragments: + self.assertIn(fragment, prose) + for claim in stale_claims: + self.assertNotIn(claim, prose) + + +if __name__ == "__main__": + unittest.main()