Route operator updates through the local signed helper

This commit is contained in:
rcourtman 2026-04-22 16:18:16 +01:00
parent 0f767b6439
commit a60fa03d7f
13 changed files with 167 additions and 76 deletions

View file

@ -67,10 +67,19 @@ Power-user shortcuts:
## ⚡ Quick Start
### Option 1: Proxmox LXC (Recommended)
Run this one-liner on your Proxmox host to create a lightweight LXC container:
Replace `vX.Y.Z` with the exact release tag you want, verify the signed installer, then run it on your Proxmox host:
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | 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"
ssh-keygen -Y verify \
-f <(printf '%s\n' 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDs21c5oPk2khrdHlsw1aZ9EJKoTsyalGzhb0hdwJrkV pulse-installer') \
-I pulse-installer \
-n pulse-install \
-s install.sh.sshsig < install.sh
bash install.sh --version "${PULSE_VERSION}"
rm -f install.sh install.sh.sshsig
```
Note: this installs the Pulse **server**. Agent installs use the command generated in **Settings → Unified Agents → Installation commands** (served from `/install.sh` on your Pulse server).
@ -82,7 +91,7 @@ docker run -d \
-p 7655:7655 \
-v pulse_data:/data \
--restart unless-stopped \
rcourtman/pulse:latest
rcourtman/pulse:vX.Y.Z
```
Access the dashboard at `http://<your-ip>:7655`.

View file

@ -74,7 +74,7 @@ Auto-update preferences are stored in `system.json` and edited via the UI.
```bash
# Pull latest image
docker pull rcourtman/pulse:latest
docker pull rcourtman/pulse:vX.Y.Z
# Restart container
docker compose down && docker compose up -d
@ -85,18 +85,18 @@ If you use the legacy `docker-compose` binary, replace `docker compose` with `do
### ProxmoxVE LXC (Manual)
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash
sudo /bin/update
```
This script installs/updates the **Pulse server**. Agent updates use the `/install.sh` command generated in **Settings → Infrastructure → Install on a host**.
`/bin/update` is installed by the supported Pulse server installer and preserves the signed-installer trust chain. If your host does not have it yet, use the signed server-installer flow in [INSTALL.md](INSTALL.md). Agent updates still use the `/install.sh` command generated in **Settings → Infrastructure → Install on a host**.
### Systemd Service (Manual)
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash
sudo /bin/update
```
This script installs/updates the **Pulse server**. Agent updates use the `/install.sh` command generated in **Settings → Infrastructure → Install on a host**.
`/bin/update` is installed by the supported Pulse server installer and preserves the signed-installer trust chain. If your host does not have it yet, use the signed server-installer flow in [INSTALL.md](INSTALL.md). Agent updates still use the `/install.sh` command generated in **Settings → Infrastructure → Install on a host**.
### Source Build

View file

@ -10,7 +10,7 @@ docker run -d \
-p 7655:7655 \
-v pulse_data:/data \
--restart unless-stopped \
rcourtman/pulse:latest
rcourtman/pulse:vX.Y.Z
```
Access at `http://<your-ip>:7655`.
@ -24,7 +24,7 @@ Create a `docker-compose.yml` file:
```yaml
services:
pulse:
image: rcourtman/pulse:latest
image: rcourtman/pulse:vX.Y.Z
container_name: pulse
restart: unless-stopped
ports:
@ -86,10 +86,10 @@ services:
## 🔄 Updates
To update Pulse to the latest version:
To update Pulse to a specific release tag:
```bash
docker pull rcourtman/pulse:latest
docker pull rcourtman/pulse:vX.Y.Z
docker stop pulse
docker rm pulse
# Re-run your docker run command
@ -168,7 +168,7 @@ Pulse provides granular control over update features via environment variables o
```yaml
services:
pulse:
image: rcourtman/pulse:latest
image: rcourtman/pulse:vX.Y.Z
environment:
- PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=true
```

View file

@ -3,19 +3,11 @@
## 🛠️ Installation & Setup
### What's the easiest way to install?
If you run Proxmox VE, use the official LXC installer (recommended):
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | bash
```
Note: this installs the Pulse **server**. Agent installs use the command from **Settings → Infrastructure → Install on a host** (served from `/install.sh` on your Pulse server).
If you run Proxmox VE, use the signed LXC installer flow in [INSTALL.md](INSTALL.md) and replace `vX.Y.Z` with the exact release tag you want.
If you prefer Docker:
```bash
docker run -d --name pulse -p 7655:7655 -v pulse_data:/data rcourtman/pulse:latest
```
Use a pinned image tag such as `rcourtman/pulse:vX.Y.Z` instead of `:latest`.
See [INSTALL.md](INSTALL.md) for all options (Docker Compose, Kubernetes, systemd).

View file

@ -7,10 +7,19 @@ Pulse offers flexible installation options from Docker to enterprise-ready Kuber
### Proxmox VE (LXC installer)
If you run Proxmox VE, the easiest and most “Pulse-native” deployment is the official installer which creates and configures a lightweight LXC container.
Run this on your Proxmox host:
Replace `vX.Y.Z` with the exact release tag you want, then run this on your Proxmox host:
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | 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"
ssh-keygen -Y verify \
-f <(printf '%s\n' 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDs21c5oPk2khrdHlsw1aZ9EJKoTsyalGzhb0hdwJrkV pulse-installer') \
-I pulse-installer \
-n pulse-install \
-s install.sh.sshsig < install.sh
bash install.sh --version "${PULSE_VERSION}"
rm -f install.sh install.sh.sshsig
```
> **Note**: The GitHub `install.sh` is the **server** installer. The agent installer is served from your Pulse server at `/install.sh` (see **Settings → Infrastructure → Install on a host**).
@ -24,7 +33,7 @@ docker run -d \
-p 7655:7655 \
-v pulse_data:/data \
--restart unless-stopped \
rcourtman/pulse:latest
rcourtman/pulse:vX.Y.Z
```
### Docker Compose
@ -33,7 +42,7 @@ Create a `docker-compose.yml` file:
```yaml
services:
pulse:
image: rcourtman/pulse:latest
image: rcourtman/pulse:vX.Y.Z
container_name: pulse
restart: unless-stopped
ports:
@ -71,7 +80,16 @@ See [KUBERNETES.md](KUBERNETES.md) for ingress and persistence configuration.
For Linux servers (VM or bare metal), use the official installer:
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | sudo 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"
ssh-keygen -Y verify \
-f <(printf '%s\n' 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDs21c5oPk2khrdHlsw1aZ9EJKoTsyalGzhb0hdwJrkV pulse-installer') \
-I pulse-installer \
-n pulse-install \
-s install.sh.sshsig < install.sh
sudo bash install.sh --version "${PULSE_VERSION}"
rm -f install.sh install.sh.sshsig
```
> **Note**: This installs the Pulse server. Use the `/install.sh` endpoint from **Settings → Infrastructure → Install on a host** for installing `pulse-agent` on monitored hosts.
@ -149,9 +167,9 @@ Pulse can self-update to the latest stable version.
| Platform | Command |
|----------|---------|
| **Docker** | `docker pull rcourtman/pulse:latest && docker restart pulse` |
| **Docker** | `docker pull rcourtman/pulse:vX.Y.Z && docker restart pulse` |
| **Kubernetes** | `helm repo update && helm upgrade pulse pulse/pulse -n pulse` |
| **Systemd** | Re-download binary and restart service |
| **Systemd / Proxmox LXC** | `sudo /bin/update` |
### Rollback
If an update causes issues on systemd installations, backups are created automatically during the update process.

View file

@ -16,19 +16,18 @@ Preferred path:
- **Settings → System → Updates**
If you prefer CLI, use the official installer for the target version:
If you prefer CLI, use the installed update helper for the target version:
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | \
sudo bash -s -- --version vX.Y.Z
sudo /bin/update --version vX.Y.Z
```
This installer updates the **Pulse server**. Agent updates use the `/install.sh` command generated in **Settings → Infrastructure → Install on a host**.
`/bin/update` is installed by the supported systemd and Proxmox LXC server installer. If your host does not have it yet, follow the signed server-installer flow in [INSTALL.md](INSTALL.md). Agent updates still use the `/install.sh` command generated in **Settings → Infrastructure → Install on a host**.
### Docker
```bash
docker pull rcourtman/pulse:latest
docker pull rcourtman/pulse:vX.Y.Z
docker compose up -d
```

View file

@ -21,19 +21,18 @@ Preferred path:
- **Settings → System → Updates**
If you prefer CLI, use the official installer for the target version:
If you prefer CLI, use the installed update helper for the target version:
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | \
sudo bash -s -- --version vX.Y.Z
sudo /bin/update --version vX.Y.Z
```
This installer updates the **Pulse server**. Agent updates use the `/install.sh` command generated in **Settings → Infrastructure → Install on a host**.
`/bin/update` is installed by the supported systemd and Proxmox LXC server installer. If your host does not have it yet, follow the signed server-installer flow in [INSTALL.md](INSTALL.md). Agent updates still use the `/install.sh` command generated in **Settings → Infrastructure → Install on a host**.
### Docker
```bash
docker pull rcourtman/pulse:latest
docker pull rcourtman/pulse:vX.Y.Z
docker compose up -d
```

View file

@ -46,7 +46,6 @@ If an update fails:
2. The timer script keeps a temporary backup under `/tmp/pulse-backup-<timestamp>` during the update; failures auto-restore from that backup and then clean it up.
3. If you need to pin a specific version, re-run the installer with a version:
```bash
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh | \
sudo bash -s -- --version vX.Y.Z
sudo /bin/update --version vX.Y.Z
```
This installer updates the **Pulse server**. Agent updates use the `/install.sh` command generated in **Settings → Infrastructure → Install on a host**.
`/bin/update` is installed by the supported Pulse server installer. If your host does not have it yet, use the signed server-installer flow in [INSTALL.md](../INSTALL.md). Agent updates still use the `/install.sh` command generated in **Settings → Infrastructure → Install on a host**.

View file

@ -513,6 +513,12 @@ root `install.sh`, its generated update helper, and
installer scripts against the pinned release `.sshsig` sidecars before
execution, rather than treating same-origin checksum files as a sufficient
trust anchor.
That same boundary also owns operator-facing management entry points for
existing self-hosted installs: the installer's printed update/reset/uninstall
commands and the active install or upgrade docs must route supported
systemd/LXC servers through the installed local update helper (`/bin/update`
or the service-scoped equivalent), rather than telling operators to pipe a
freshly downloaded installer into `bash`.
The local dev-runtime launcher and dependency manifest floor now sit on that
same installability boundary.
`scripts/hot-dev.sh` and `scripts/hot-dev-bg.sh` are the canonical owned entry

View file

@ -3347,17 +3347,37 @@ installer_env=(
"PULSE_UPDATE_SERVICE_PATH=\$PULSE_UPDATE_SERVICE_PATH"
"PULSE_UPDATE_TIMER_PATH=\$PULSE_UPDATE_TIMER_PATH"
)
if [[ -f "\$MARKER_FILE" ]]; then
branch=\$(tr -d '\r\n' <"\$MARKER_FILE" 2>/dev/null || true)
if [[ -n "\$branch" ]]; then
extra_args+=(--source "\$branch")
fi
elif [[ -f "\${CONFIG_DIR}/system.json" ]]; then
configured_channel=\$(grep -o '"updateChannel"[[:space:]]*:[[:space:]]*"[^"]*"' "\${CONFIG_DIR}/system.json" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/' || true)
if [[ "\$configured_channel" == "rc" ]]; then
extra_args+=(--rc)
helper_args=()
if [[ \$# -gt 0 ]]; then
helper_args=("\$@")
fi
auto_selector_allowed=true
if [[ \${#helper_args[@]} -gt 0 ]]; then
for helper_arg in "\${helper_args[@]}"; do
case "\$helper_arg" in
-h|--help|--uninstall|--version|--rc|--pre|--stable|--source|--from-source|--branch|--archive|--archive=*)
auto_selector_allowed=false
break
;;
esac
done
fi
if [[ "\$auto_selector_allowed" == "true" ]]; then
if [[ -f "\$MARKER_FILE" ]]; then
branch=\$(tr -d '\r\n' <"\$MARKER_FILE" 2>/dev/null || true)
if [[ -n "\$branch" ]]; then
extra_args+=(--source "\$branch")
fi
elif [[ -f "\${CONFIG_DIR}/system.json" ]]; then
configured_channel=\$(grep -o '"updateChannel"[[:space:]]*:[[:space:]]*"[^"]*"' "\${CONFIG_DIR}/system.json" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/' || true)
if [[ "\$configured_channel" == "rc" ]]; then
extra_args+=(--rc)
fi
fi
fi
if [[ \${#helper_args[@]} -gt 0 ]]; then
extra_args+=("\${helper_args[@]}")
fi
echo "Updating Pulse..."
tmp_installer=\$(mktemp /tmp/pulse-update-installer.XXXXXX)
@ -3786,9 +3806,8 @@ print_completion() {
build_printed_management_command() {
local action=$1
local download_cmd="curl -sSL https://github.com/$GITHUB_REPO/releases/latest/download/install.sh |"
local update_helper_path="${UPDATE_HELPER_PATH:-${PULSE_UPDATE_HELPER_PATH:-/bin/update}}"
local -a args=()
local -a env_vars=()
case "$action" in
update)
@ -3810,28 +3829,13 @@ build_printed_management_command() {
args=(--rc "${args[@]}")
fi
if [[ "$SERVICE_NAME_EXPLICIT" == "true" ]]; then
env_vars+=("PULSE_SERVICE_NAME=$SERVICE_NAME")
fi
printf '%s' "$download_cmd"
local env_var
if [[ ${#env_vars[@]} -gt 0 ]]; then
printf ' env'
for env_var in "${env_vars[@]}"; do
printf ' %q' "$env_var"
done
printf ' bash'
else
printf ' bash'
fi
printf '%q' "$update_helper_path"
if [[ ${#args[@]} -eq 0 ]]; then
printf '\n'
return 0
fi
printf ' -s --'
local arg
for arg in "${args[@]}"; do
printf ' %q' "$arg"

View file

@ -1138,6 +1138,15 @@ func TestSetupUpdateCommandHonorsRCChannelAndCustomPaths(t *testing.T) {
if !strings.Contains(got, `CONFIG_DIR=/etc/pulse`) {
t.Fatalf("update helper missing config dir logic:\n%s", got)
}
if !strings.Contains(got, `helper_args=()`) || !strings.Contains(got, `helper_args=("$@")`) {
t.Fatalf("update helper missing passthrough helper args:\n%s", got)
}
if !strings.Contains(got, `-h|--help|--uninstall|--version|--rc|--pre|--stable|--source|--from-source|--branch|--archive|--archive=*)`) {
t.Fatalf("update helper missing auto-selector guard for explicit flags:\n%s", got)
}
if !strings.Contains(got, `extra_args+=("${helper_args[@]}")`) {
t.Fatalf("update helper missing forwarded helper args:\n%s", got)
}
if !strings.Contains(got, `extra_args+=(--rc)`) {
t.Fatalf("update helper missing rc channel forwarding:\n%s", got)
}
@ -1925,10 +1934,10 @@ func TestBuildPrintedManagementCommandPreservesRCChannel(t *testing.T) {
if len(lines) != 3 {
t.Fatalf("expected 3 commands, got %d:\n%s", len(lines), out)
}
if !strings.Contains(lines[0], "| bash -s -- --rc") {
if got := lines[0]; got != "/bin/update --rc" {
t.Fatalf("update command missing rc flag: %s", lines[0])
}
if !strings.Contains(lines[1], "| bash -s -- --rc --reset") {
if got := lines[1]; got != "/bin/update --rc --reset" {
t.Fatalf("reset command missing rc flag: %s", lines[1])
}
if strings.Contains(lines[2], "--rc") {
@ -1956,14 +1965,35 @@ func TestBuildPrintedManagementCommandPreservesForcedVersion(t *testing.T) {
if len(lines) != 2 {
t.Fatalf("expected 2 commands, got %d:\n%s", len(lines), out)
}
if !strings.Contains(lines[0], "| bash -s -- --version v1.2.3") {
if got := lines[0]; got != "/bin/update --version v1.2.3" {
t.Fatalf("update command missing version pin: %s", lines[0])
}
if !strings.Contains(lines[1], "| bash -s -- --version v1.2.3 --reset") {
if got := lines[1]; got != "/bin/update --version v1.2.3 --reset" {
t.Fatalf("reset command missing version pin: %s", lines[1])
}
}
func TestBuildPrintedManagementCommandUsesConfiguredHelperPath(t *testing.T) {
script := `
GITHUB_REPO="rcourtman/Pulse"
FORCE_VERSION=""
FORCE_CHANNEL=""
UPDATE_CHANNEL=""
UPDATE_HELPER_PATH="/usr/local/bin/update-pulse-preview"
` + extractRootInstallShellFunction(t, "build_printed_management_command") + `
build_printed_management_command update
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
if got := strings.TrimSpace(string(out)); got != "/usr/local/bin/update-pulse-preview" {
t.Fatalf("printed command = %q, want configured helper path", got)
}
}
func TestSelectedUpdateChannelTreatsPrereleaseVersionAsRC(t *testing.T) {
script := `
FORCE_CHANNEL=""

View file

@ -241,8 +241,8 @@ func TestRootInstallScriptSupportsInstanceScopedServerInstalls(t *testing.T) {
`Environment="PULSE_INSTALL_DIR=$install_dir"`,
`Environment="PULSE_CONFIG_DIR=$config_dir"`,
`Environment="PULSE_UPDATE_TIMER_UNIT=$update_timer_unit"`,
`printf ' env'`,
`env_vars+=("PULSE_SERVICE_NAME=$SERVICE_NAME")`,
`local update_helper_path="${UPDATE_HELPER_PATH:-${PULSE_UPDATE_HELPER_PATH:-/bin/update}}"`,
`printf '%q' "$update_helper_path"`,
}
for _, needle := range required {
if !strings.Contains(script, needle) {
@ -324,3 +324,36 @@ func TestPulseAutoUpdateScriptRequiresSignedInstallerDownloads(t *testing.T) {
}
}
}
func TestOperatorInstallDocsAvoidUnverifiedBootstrapAndFloatingImageTags(t *testing.T) {
files := []string{
filepath.Join("..", "..", "README.md"),
filepath.Join("..", "..", "docs", "INSTALL.md"),
filepath.Join("..", "..", "docs", "UPGRADE_v6.md"),
filepath.Join("..", "..", "docs", "UPGRADE_v5.md"),
filepath.Join("..", "..", "docs", "DOCKER.md"),
filepath.Join("..", "..", "docs", "AUTO_UPDATE.md"),
filepath.Join("..", "..", "docs", "operations", "AUTO_UPDATE.md"),
filepath.Join("..", "..", "docs", "FAQ.md"),
}
forbidden := []string{
`curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh |`,
`curl -sL https://github.com/rcourtman/Pulse/releases/latest/download/install.sh |`,
`rcourtman/pulse:latest`,
`docker pull rcourtman/pulse:latest`,
`image: rcourtman/pulse:latest`,
}
for _, path := range files {
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
text := string(content)
for _, needle := range forbidden {
if strings.Contains(text, needle) {
t.Fatalf("%s preserved insecure operator guidance: %s", path, needle)
}
}
}
}

View file

@ -157,6 +157,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
def test_upgrade_guide_points_at_current_rc_support_pack(self) -> None:
upgrade_guide = read("docs/UPGRADE_v6.md")
current_version = read("VERSION").strip()
self.assertIn("sudo /bin/update --version vX.Y.Z", upgrade_guide)
self.assertIn("follow the signed server-installer flow in [INSTALL.md](INSTALL.md)", upgrade_guide)
if current_version == "6.0.0":
self.assertIn("docs/releases/RELEASE_NOTES_v6.md", upgrade_guide)
self.assertIn("docs/releases/V6_CHANGELOG.md", upgrade_guide)