mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Merge reviewed PBS private diagnostic guidance
Accept the exact two-commit product-intelligence candidate with retained copied-command and Docs proof; browser rendering remains unverified. Change-source: pulse-maintainer
This commit is contained in:
+106
-25
@@ -86,27 +86,67 @@ for monitoring.
|
||||
|
||||
On the PBS host, enter an administrator root shell before preparing the private
|
||||
file below. In the editor, save only the Pulse agent token, with no header or
|
||||
quotes; never put the secret in a command argument:
|
||||
quotes; never put the secret in a command argument. The preparation preserves
|
||||
an existing file and refuses symlinked or non-regular credential paths. Stop
|
||||
if it fails; do not continue to the installer:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
mkdir -p "$HOME/.config/pulse"
|
||||
chmod 700 "$HOME/.config/pulse"
|
||||
touch "$HOME/.config/pulse/pbs-agent-token"
|
||||
chmod 600 "$HOME/.config/pulse/pbs-agent-token"
|
||||
vi "$HOME/.config/pulse/pbs-agent-token"
|
||||
(
|
||||
set -eu
|
||||
umask 077
|
||||
config_dir="$HOME/.config/pulse"
|
||||
credential_file="$config_dir/pbs-agent-token"
|
||||
if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ]; then
|
||||
printf 'Refusing a symlinked credential path.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -e "$credential_file" ] && [ ! -f "$credential_file" ]; then
|
||||
printf 'Credential file must be a regular file.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$config_dir"
|
||||
chmod 700 "$config_dir"
|
||||
touch "$credential_file"
|
||||
chmod 600 "$credential_file"
|
||||
vi "$credential_file"
|
||||
)
|
||||
```
|
||||
|
||||
Download the agent installer from **your Pulse server's HTTPS address**, then
|
||||
inspect the saved script before running it. Replace the example Pulse URL in
|
||||
both commands. Stop if the download fails; run the next command only after a
|
||||
successful download and inspection. Do not substitute GitHub's top-level `install.sh`: that installs
|
||||
the Pulse server, not the agent.
|
||||
successful HTTP **200** download and inspection. This ignores curl configuration
|
||||
and saves only a successful download to the installer path. It refuses an
|
||||
existing installer rather than overwriting it; review that file locally before
|
||||
deciding whether a new download is needed. Do not substitute GitHub's top-level
|
||||
`install.sh`: that installs the Pulse server, not the agent.
|
||||
|
||||
```bash
|
||||
curl --fail --silent --show-error \
|
||||
--output "$HOME/.config/pulse/pbs-agent-install.sh" \
|
||||
https://pulse.example.com/install.sh
|
||||
(
|
||||
set -eu
|
||||
umask 077
|
||||
config_dir="$HOME/.config/pulse"
|
||||
installer_file="$config_dir/pbs-agent-install.sh"
|
||||
if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ ! -d "$config_dir" ]; then
|
||||
printf 'Prepare the private token directory first.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -e "$installer_file" ] || [ -L "$installer_file" ]; then
|
||||
printf 'Refusing to replace an existing installer path.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 700 "$config_dir"
|
||||
download_file=$(mktemp "$config_dir/pbs-agent-download.XXXXXX")
|
||||
curl_exit=0
|
||||
status=$(curl --disable --fail --silent --show-error --proto '=https' \
|
||||
--connect-timeout 5 --max-time 60 --output "$download_file" \
|
||||
--write-out '%{http_code}' https://pulse.example.com/install.sh) || curl_exit=$?
|
||||
printf 'HTTP %s\n' "$status"
|
||||
[ "$curl_exit" -eq 0 ] || exit "$curl_exit"
|
||||
[ "$status" = 200 ]
|
||||
mv -n "$download_file" "$installer_file"
|
||||
[ ! -e "$download_file" ]
|
||||
)
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -117,7 +157,10 @@ bash "$HOME/.config/pulse/pbs-agent-install.sh" \
|
||||
```
|
||||
|
||||
Do not bypass certificate checks to fetch or run the installer. Use a trusted
|
||||
Pulse certificate or a CA file you verified separately. See
|
||||
Pulse certificate or add `--cacert` with a CA file you verified separately.
|
||||
A failed or redirected download is not an installer: do not run its temporary
|
||||
file. Keep `--disable` first in both curl examples on this page; it ignores
|
||||
local settings that could enable tracing, bypass TLS or follow redirects. See
|
||||
[Unified Agent Setup](UNIFIED_AGENT.md) for installer trust and other profiles.
|
||||
After installation, check a fresh agent report and the host's
|
||||
`pulse-agent --version`; installation or hardware capacity alone does not prove
|
||||
@@ -177,15 +220,30 @@ another machine does not prove that Pulse can reach PBS.
|
||||
```
|
||||
|
||||
- **Authentication/permissions:** test a datastore request, not just `/version`.
|
||||
Prepare a private header file on the machine running curl:
|
||||
Prepare a private header file on the machine running curl. Stop if preparation
|
||||
fails; an existing file is preserved, and symlinked or non-regular credential
|
||||
paths are refused before opening the editor:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
mkdir -p "$HOME/.config/pulse"
|
||||
chmod 700 "$HOME/.config/pulse"
|
||||
touch "$HOME/.config/pulse/pbs-header"
|
||||
chmod 600 "$HOME/.config/pulse/pbs-header"
|
||||
vi "$HOME/.config/pulse/pbs-header"
|
||||
(
|
||||
set -eu
|
||||
umask 077
|
||||
config_dir="$HOME/.config/pulse"
|
||||
credential_file="$config_dir/pbs-header"
|
||||
if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ]; then
|
||||
printf 'Refusing a symlinked credential path.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -e "$credential_file" ] && [ ! -f "$credential_file" ]; then
|
||||
printf 'Credential file must be a regular file.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$config_dir"
|
||||
chmod 700 "$config_dir"
|
||||
touch "$credential_file"
|
||||
chmod 600 "$credential_file"
|
||||
vi "$credential_file"
|
||||
)
|
||||
```
|
||||
|
||||
In the editor, save this line, replacing `<pbs-token-secret>` with the secret
|
||||
@@ -195,12 +253,33 @@ another machine does not prove that Pulse can reach PBS.
|
||||
Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token:<pbs-token-secret>
|
||||
```
|
||||
|
||||
Then use curl 7.76 or later, with your PBS hostname:
|
||||
Then use curl 7.76 or later, with your PBS hostname. The request prints only
|
||||
the HTTP status and a new private response-file path; inspect that file locally,
|
||||
not in a public terminal recording or thread:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 15 \
|
||||
--header "@$HOME/.config/pulse/pbs-header" \
|
||||
https://pbs.example.com:8007/api2/json/admin/datastore
|
||||
(
|
||||
set -eu
|
||||
umask 077
|
||||
config_dir="$HOME/.config/pulse"
|
||||
credential_file="$config_dir/pbs-header"
|
||||
if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ] || [ ! -f "$credential_file" ]; then
|
||||
printf 'Prepare the private header file first.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 700 "$config_dir"
|
||||
chmod 600 "$credential_file"
|
||||
result_file=$(mktemp "$config_dir/pbs-response.XXXXXX")
|
||||
printf 'Private response file: %s\n' "$result_file"
|
||||
curl_exit=0
|
||||
status=$(curl --disable --fail-with-body --silent --show-error --proto '=https' \
|
||||
--connect-timeout 5 --max-time 15 --header "@$credential_file" \
|
||||
--output "$result_file" --write-out '%{http_code}' \
|
||||
https://pbs.example.com:8007/api2/json/admin/datastore) || curl_exit=$?
|
||||
printf 'HTTP %s\n' "$status"
|
||||
[ "$curl_exit" -eq 0 ] || exit "$curl_exit"
|
||||
[ "$status" = 200 ]
|
||||
)
|
||||
```
|
||||
|
||||
For curl with a private CA or self-signed certificate, add
|
||||
@@ -209,7 +288,9 @@ another machine does not prove that Pulse can reach PBS.
|
||||
use `--insecure` or `-k`.
|
||||
|
||||
`401` means authentication was rejected; `403` means the requested access was
|
||||
refused. Both return a non-zero curl exit while retaining the error body.
|
||||
refused. Both return a non-zero curl exit while retaining the error body
|
||||
privately. A redirect or `000` is not successful access; this command neither
|
||||
follows redirects nor retries.
|
||||
Inspect the existing token and both `Audit` grants before replacing anything.
|
||||
A `200` response listing the expected datastore establishes access to that
|
||||
list, not successful collection of every backup, job or History graph. An
|
||||
|
||||
@@ -86,27 +86,67 @@ for monitoring.
|
||||
|
||||
On the PBS host, enter an administrator root shell before preparing the private
|
||||
file below. In the editor, save only the Pulse agent token, with no header or
|
||||
quotes; never put the secret in a command argument:
|
||||
quotes; never put the secret in a command argument. The preparation preserves
|
||||
an existing file and refuses symlinked or non-regular credential paths. Stop
|
||||
if it fails; do not continue to the installer:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
mkdir -p "$HOME/.config/pulse"
|
||||
chmod 700 "$HOME/.config/pulse"
|
||||
touch "$HOME/.config/pulse/pbs-agent-token"
|
||||
chmod 600 "$HOME/.config/pulse/pbs-agent-token"
|
||||
vi "$HOME/.config/pulse/pbs-agent-token"
|
||||
(
|
||||
set -eu
|
||||
umask 077
|
||||
config_dir="$HOME/.config/pulse"
|
||||
credential_file="$config_dir/pbs-agent-token"
|
||||
if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ]; then
|
||||
printf 'Refusing a symlinked credential path.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -e "$credential_file" ] && [ ! -f "$credential_file" ]; then
|
||||
printf 'Credential file must be a regular file.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$config_dir"
|
||||
chmod 700 "$config_dir"
|
||||
touch "$credential_file"
|
||||
chmod 600 "$credential_file"
|
||||
vi "$credential_file"
|
||||
)
|
||||
```
|
||||
|
||||
Download the agent installer from **your Pulse server's HTTPS address**, then
|
||||
inspect the saved script before running it. Replace the example Pulse URL in
|
||||
both commands. Stop if the download fails; run the next command only after a
|
||||
successful download and inspection. Do not substitute GitHub's top-level `install.sh`: that installs
|
||||
the Pulse server, not the agent.
|
||||
successful HTTP **200** download and inspection. This ignores curl configuration
|
||||
and saves only a successful download to the installer path. It refuses an
|
||||
existing installer rather than overwriting it; review that file locally before
|
||||
deciding whether a new download is needed. Do not substitute GitHub's top-level
|
||||
`install.sh`: that installs the Pulse server, not the agent.
|
||||
|
||||
```bash
|
||||
curl --fail --silent --show-error \
|
||||
--output "$HOME/.config/pulse/pbs-agent-install.sh" \
|
||||
https://pulse.example.com/install.sh
|
||||
(
|
||||
set -eu
|
||||
umask 077
|
||||
config_dir="$HOME/.config/pulse"
|
||||
installer_file="$config_dir/pbs-agent-install.sh"
|
||||
if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ ! -d "$config_dir" ]; then
|
||||
printf 'Prepare the private token directory first.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -e "$installer_file" ] || [ -L "$installer_file" ]; then
|
||||
printf 'Refusing to replace an existing installer path.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 700 "$config_dir"
|
||||
download_file=$(mktemp "$config_dir/pbs-agent-download.XXXXXX")
|
||||
curl_exit=0
|
||||
status=$(curl --disable --fail --silent --show-error --proto '=https' \
|
||||
--connect-timeout 5 --max-time 60 --output "$download_file" \
|
||||
--write-out '%{http_code}' https://pulse.example.com/install.sh) || curl_exit=$?
|
||||
printf 'HTTP %s\n' "$status"
|
||||
[ "$curl_exit" -eq 0 ] || exit "$curl_exit"
|
||||
[ "$status" = 200 ]
|
||||
mv -n "$download_file" "$installer_file"
|
||||
[ ! -e "$download_file" ]
|
||||
)
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -117,7 +157,10 @@ bash "$HOME/.config/pulse/pbs-agent-install.sh" \
|
||||
```
|
||||
|
||||
Do not bypass certificate checks to fetch or run the installer. Use a trusted
|
||||
Pulse certificate or a CA file you verified separately. See
|
||||
Pulse certificate or add `--cacert` with a CA file you verified separately.
|
||||
A failed or redirected download is not an installer: do not run its temporary
|
||||
file. Keep `--disable` first in both curl examples on this page; it ignores
|
||||
local settings that could enable tracing, bypass TLS or follow redirects. See
|
||||
[Unified Agent Setup](UNIFIED_AGENT.md) for installer trust and other profiles.
|
||||
After installation, check a fresh agent report and the host's
|
||||
`pulse-agent --version`; installation or hardware capacity alone does not prove
|
||||
@@ -177,15 +220,30 @@ another machine does not prove that Pulse can reach PBS.
|
||||
```
|
||||
|
||||
- **Authentication/permissions:** test a datastore request, not just `/version`.
|
||||
Prepare a private header file on the machine running curl:
|
||||
Prepare a private header file on the machine running curl. Stop if preparation
|
||||
fails; an existing file is preserved, and symlinked or non-regular credential
|
||||
paths are refused before opening the editor:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
mkdir -p "$HOME/.config/pulse"
|
||||
chmod 700 "$HOME/.config/pulse"
|
||||
touch "$HOME/.config/pulse/pbs-header"
|
||||
chmod 600 "$HOME/.config/pulse/pbs-header"
|
||||
vi "$HOME/.config/pulse/pbs-header"
|
||||
(
|
||||
set -eu
|
||||
umask 077
|
||||
config_dir="$HOME/.config/pulse"
|
||||
credential_file="$config_dir/pbs-header"
|
||||
if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ]; then
|
||||
printf 'Refusing a symlinked credential path.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -e "$credential_file" ] && [ ! -f "$credential_file" ]; then
|
||||
printf 'Credential file must be a regular file.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$config_dir"
|
||||
chmod 700 "$config_dir"
|
||||
touch "$credential_file"
|
||||
chmod 600 "$credential_file"
|
||||
vi "$credential_file"
|
||||
)
|
||||
```
|
||||
|
||||
In the editor, save this line, replacing `<pbs-token-secret>` with the secret
|
||||
@@ -195,12 +253,33 @@ another machine does not prove that Pulse can reach PBS.
|
||||
Authorization: PBSAPIToken=pulse-monitor@pbs!pulse-token:<pbs-token-secret>
|
||||
```
|
||||
|
||||
Then use curl 7.76 or later, with your PBS hostname:
|
||||
Then use curl 7.76 or later, with your PBS hostname. The request prints only
|
||||
the HTTP status and a new private response-file path; inspect that file locally,
|
||||
not in a public terminal recording or thread:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body --silent --show-error --connect-timeout 5 --max-time 15 \
|
||||
--header "@$HOME/.config/pulse/pbs-header" \
|
||||
https://pbs.example.com:8007/api2/json/admin/datastore
|
||||
(
|
||||
set -eu
|
||||
umask 077
|
||||
config_dir="$HOME/.config/pulse"
|
||||
credential_file="$config_dir/pbs-header"
|
||||
if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$credential_file" ] || [ ! -f "$credential_file" ]; then
|
||||
printf 'Prepare the private header file first.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 700 "$config_dir"
|
||||
chmod 600 "$credential_file"
|
||||
result_file=$(mktemp "$config_dir/pbs-response.XXXXXX")
|
||||
printf 'Private response file: %s\n' "$result_file"
|
||||
curl_exit=0
|
||||
status=$(curl --disable --fail-with-body --silent --show-error --proto '=https' \
|
||||
--connect-timeout 5 --max-time 15 --header "@$credential_file" \
|
||||
--output "$result_file" --write-out '%{http_code}' \
|
||||
https://pbs.example.com:8007/api2/json/admin/datastore) || curl_exit=$?
|
||||
printf 'HTTP %s\n' "$status"
|
||||
[ "$curl_exit" -eq 0 ] || exit "$curl_exit"
|
||||
[ "$status" = 200 ]
|
||||
)
|
||||
```
|
||||
|
||||
For curl with a private CA or self-signed certificate, add
|
||||
@@ -209,7 +288,9 @@ another machine does not prove that Pulse can reach PBS.
|
||||
use `--insecure` or `-k`.
|
||||
|
||||
`401` means authentication was rejected; `403` means the requested access was
|
||||
refused. Both return a non-zero curl exit while retaining the error body.
|
||||
refused. Both return a non-zero curl exit while retaining the error body
|
||||
privately. A redirect or `000` is not successful access; this command neither
|
||||
follows redirects nor retries.
|
||||
Inspect the existing token and both `Audit` grants before replacing anything.
|
||||
A `200` response listing the expected datastore establishes access to that
|
||||
list, not successful collection of every backup, job or History graph. An
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
package installtests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Extract the actual copied commands, including the old guide for an exact
|
||||
// parent control. These tests contact only synthetic peers in the proof VM.
|
||||
func pbsAPIBlocks(doc string) ([4]string, error) {
|
||||
var blocks [4]string
|
||||
for _, match := range regexp.MustCompile("(?s)```bash\n(.*?)```").FindAllStringSubmatch(doc, -1) {
|
||||
block := match[1]
|
||||
index := -1
|
||||
switch {
|
||||
case strings.Contains(block, "pbs-agent-token") && strings.Contains(block, "vi "):
|
||||
index = 0
|
||||
case strings.Contains(block, "pbs-header") && strings.Contains(block, "vi "):
|
||||
index = 1
|
||||
case strings.Contains(block, "pbs-agent-install.sh") && strings.Contains(block, "curl "):
|
||||
index = 2
|
||||
case strings.Contains(block, "https://pbs.example.com:8007/api2/json/admin/datastore"):
|
||||
index = 3
|
||||
}
|
||||
if index >= 0 {
|
||||
if blocks[index] != "" {
|
||||
return blocks, fmt.Errorf("duplicate PBS recipe %d", index)
|
||||
}
|
||||
blocks[index] = block
|
||||
}
|
||||
}
|
||||
for i, block := range blocks {
|
||||
if block == "" {
|
||||
return blocks, fmt.Errorf("missing PBS recipe %d", i)
|
||||
}
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func pbsAPISafety(blocks [4]string) error {
|
||||
for i, block := range blocks {
|
||||
required := []string{"set -eu", "umask 077", `[ -L "$HOME/.config" ]`, `[ -L "$config_dir" ]`, `chmod 700 "$config_dir"`}
|
||||
if i < 2 {
|
||||
required = append(required, `[ -L "$credential_file" ]`, `[ ! -f "$credential_file" ]`, `chmod 600 "$credential_file"`, `vi "$credential_file"`)
|
||||
} else {
|
||||
required = append(required, "curl --disable ", "--silent --show-error --proto '=https'", "--connect-timeout 5",
|
||||
`--write-out '%{http_code}'`, `printf 'HTTP %s\n' "$status"`, `[ "$curl_exit" -eq 0 ] || exit "$curl_exit"`, `[ "$status" = 200 ]`)
|
||||
if i == 2 {
|
||||
required = append(required, "--fail ", "--max-time 60", `download_file=$(mktemp "$config_dir/pbs-agent-download.XXXXXX")`,
|
||||
`[ -e "$installer_file" ] || [ -L "$installer_file" ]`, `--output "$download_file"`, `mv -n "$download_file" "$installer_file"`, `[ ! -e "$download_file" ]`)
|
||||
} else {
|
||||
required = append(required, "--fail-with-body ", "--max-time 15", `[ -L "$credential_file" ]`, `[ ! -f "$credential_file" ]`,
|
||||
`chmod 600 "$credential_file"`, `result_file=$(mktemp "$config_dir/pbs-response.XXXXXX")`, `--header "@$credential_file"`, `--output "$result_file"`)
|
||||
}
|
||||
for _, forbidden := range []string{"--insecure", "--location", "--config", "--trace", "--verbose", "--retry", "Authorization:", " | "} {
|
||||
if strings.Contains(block, forbidden) {
|
||||
return fmt.Errorf("PBS recipe %d contains %s", i, forbidden)
|
||||
}
|
||||
}
|
||||
if strings.Count(block, "curl ") != 1 {
|
||||
return fmt.Errorf("PBS recipe %d must make one request", i)
|
||||
}
|
||||
}
|
||||
for _, text := range required {
|
||||
if !strings.Contains(block, text) {
|
||||
return fmt.Errorf("PBS recipe %d lacks %s", i, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pbsAPIGuidance(t *testing.T) [4]string {
|
||||
t.Helper()
|
||||
doc, err := os.ReadFile(repoFile("docs", "PBS.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocks, err := pbsAPIBlocks(string(doc))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pbsAPISafety(blocks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mirror, err := os.ReadFile(repoFile("frontend-modern", "public", "docs", "PBS.md"))
|
||||
if err != nil || string(mirror) != string(doc) {
|
||||
t.Fatal("shipped PBS guide differs from the verified recipes")
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func runPBSRecipe(t *testing.T, recipe, home, bin string, extraEnv ...string) (string, int) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "bash", "-c", recipe)
|
||||
cmd.Env = append(os.Environ(), "HOME="+home, "PATH="+bin+":"+os.Getenv("PATH"),
|
||||
"http_proxy=", "HTTP_PROXY=", "https_proxy=", "HTTPS_PROXY=", "ALL_PROXY=", "all_proxy=", "NO_PROXY=", "no_proxy=")
|
||||
cmd.Env = append(cmd.Env, extraEnv...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatal("copied PBS recipe exceeded its bounded test deadline")
|
||||
}
|
||||
if err == nil {
|
||||
return string(output), 0
|
||||
}
|
||||
failure, ok := err.(*exec.ExitError)
|
||||
if !ok {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(output), failure.ExitCode()
|
||||
}
|
||||
|
||||
func TestPBSAPIGuidancePreparationIsPrivateAndFailClosed(t *testing.T) {
|
||||
for index, filename := range []string{"pbs-agent-token", "pbs-header"} {
|
||||
for _, mode := range []string{"new", "existing", "config symlink", "directory symlink", "file symlink", "dangling symlink", "non-regular", "mkdir failure", "chmod failure", "touch failure", "editor failure"} {
|
||||
t.Run(filename+"/"+mode, func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
bin := filepath.Join(home, "bin")
|
||||
if err := os.Mkdir(bin, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
edited := filepath.Join(home, "editor-started")
|
||||
editor := "#!/bin/sh\nprintf started > \"$EDITOR_STARTED\"\n"
|
||||
if mode == "editor failure" {
|
||||
editor += "exit 1\n"
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(bin, "vi"), []byte(editor), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join(home, ".config", "pulse")
|
||||
file := filepath.Join(dir, filename)
|
||||
if mode == "existing" || strings.Contains(mode, "file symlink") || mode == "dangling symlink" || mode == "non-regular" {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
target := filepath.Join(home, "untouched")
|
||||
if strings.Contains(mode, "symlink") {
|
||||
link := file
|
||||
switch mode {
|
||||
case "file symlink":
|
||||
if err := os.WriteFile(target, []byte("synthetic-existing-secret"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "config symlink", "directory symlink":
|
||||
if err := os.Mkdir(target, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link = filepath.Join(home, ".config")
|
||||
if mode == "directory symlink" {
|
||||
if err := os.Mkdir(link, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link = dir
|
||||
}
|
||||
}
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if mode == "existing" {
|
||||
if err := os.WriteFile(file, []byte("synthetic-existing-secret"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if mode == "non-regular" {
|
||||
if err := os.Mkdir(file, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(mode, " failure") && mode != "editor failure" {
|
||||
if err := os.WriteFile(filepath.Join(bin, strings.TrimSuffix(mode, " failure")), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
output, exit := runPBSRecipe(t, pbsAPIGuidance(t)[index], home, bin, "EDITOR_STARTED="+edited)
|
||||
_, editorErr := os.Stat(edited)
|
||||
if mode != "new" && mode != "existing" {
|
||||
if exit == 0 || (mode != "editor failure" && !os.IsNotExist(editorErr)) {
|
||||
t.Fatal("failed or unsafe preparation was accepted or reached the editor")
|
||||
}
|
||||
if strings.Contains(mode, "symlink") && mode != "dangling symlink" {
|
||||
info, err := os.Stat(target)
|
||||
wantMode := os.FileMode(0755)
|
||||
if mode == "file symlink" {
|
||||
wantMode = 0644
|
||||
content, err := os.ReadFile(target)
|
||||
if err != nil || string(content) != "synthetic-existing-secret" {
|
||||
t.Fatal("refused credential path modified its target")
|
||||
}
|
||||
}
|
||||
if err != nil || info.Mode().Perm() != wantMode {
|
||||
t.Fatal("refused path modified target permissions")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if exit != 0 || editorErr != nil {
|
||||
t.Fatalf("safe preparation failed: exit %d, %s", exit, output)
|
||||
}
|
||||
for path, permissions := range map[string]os.FileMode{dir: 0700, file: 0600} {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Mode().Perm() != permissions {
|
||||
t.Fatal("credential path is not private")
|
||||
}
|
||||
}
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil || (mode == "existing" && string(content) != "synthetic-existing-secret") {
|
||||
t.Fatal("existing credential was not preserved")
|
||||
}
|
||||
}
|
||||
if strings.Contains(output, "synthetic-existing-secret") {
|
||||
t.Fatal("preparation disclosed a credential")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPBSAPIGuidanceRejectsUnsafeRecipesAndExactParent(t *testing.T) {
|
||||
blocks := pbsAPIGuidance(t)
|
||||
for name, edit := range map[string][2]string{
|
||||
"curl config": {"curl --disable ", "curl "}, "body disclosure": {`--output "$result_file" `, ""},
|
||||
"deadline": {"--max-time 15 ", ""}, "retry": {"curl --disable ", "curl --disable --retry 2 "},
|
||||
"redirect": {"curl --disable ", "curl --disable --location "}, "TLS bypass": {"curl --disable ", "curl --disable --insecure "},
|
||||
"inline token": {`"@$credential_file"`, `'Authorization: synthetic-inline-secret'`},
|
||||
"fail open": {"set -eu", ""}, "symlink": {`[ -L "$credential_file" ]`, "false"},
|
||||
"installer overwrite": {`mv -n "$download_file"`, `mv "$download_file"`},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
changed := blocks
|
||||
for i, block := range changed {
|
||||
changed[i] = strings.ReplaceAll(block, edit[0], edit[1])
|
||||
}
|
||||
if pbsAPISafety(changed) == nil {
|
||||
t.Fatal("unsafe mutation was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if inputs := os.Getenv("PULSE_PROOF_INPUTS"); inputs != "" {
|
||||
parent, err := os.ReadFile(filepath.Join(inputs, "pbs-parent.md"))
|
||||
if os.IsNotExist(err) {
|
||||
return // Unrelated proof inputs do not require a PBS parent.
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old, err := pbsAPIBlocks(string(parent))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pbsAPISafety(old); err == nil {
|
||||
t.Fatal("unchanged exact parent passed the new safety controls")
|
||||
} else {
|
||||
t.Logf("exact parent rejected: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actual curl sees hostile local defaults and peers that echo a fixture token
|
||||
// and identifying data. This proves copied transport/file safety, not native
|
||||
// PBS permissions, installed collection, backup recovery or agent installation.
|
||||
func TestPBSAPIGuidanceCopiedRequestsKeepSecretsPrivate(t *testing.T) {
|
||||
blocks := pbsAPIGuidance(t)
|
||||
for index, recipe := range blocks[2:] {
|
||||
for _, scenario := range []string{"success", "401", "403", "redirect", "wrong success status", "untrusted HTTPS", "truncated installer", "existing installer", "symlinked header"} {
|
||||
if (index == 0 && scenario == "symlinked header") || (index == 1 && strings.Contains(scenario, "installer")) {
|
||||
continue
|
||||
}
|
||||
t.Run(fmt.Sprintf("%d/%s", index, scenario), func(t *testing.T) {
|
||||
recipe := recipe
|
||||
realCurl, err := exec.LookPath("curl")
|
||||
if err != nil {
|
||||
t.Fatal("curl is required to verify copied PBS guidance")
|
||||
}
|
||||
home := t.TempDir()
|
||||
bin := filepath.Join(home, "bin")
|
||||
dir := filepath.Join(home, ".config", "pulse")
|
||||
for _, path := range []string{bin, dir} {
|
||||
if err := os.MkdirAll(path, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
const token = "PBSAPIToken=pulse-monitor@pbs!pulse-token:synthetic-pbs-secret"
|
||||
const body = "synthetic-pbs-secret private-datastore-name"
|
||||
header := filepath.Join(dir, "pbs-header")
|
||||
if scenario == "symlinked header" {
|
||||
target := filepath.Join(home, "outside-header")
|
||||
if err := os.WriteFile(target, []byte("Authorization: "+token+"\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err := os.WriteFile(header, []byte("Authorization: "+token+"\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installer := filepath.Join(dir, "pbs-agent-install.sh")
|
||||
if scenario == "existing installer" {
|
||||
if err := os.WriteFile(installer, []byte("original-installer"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
capture := filepath.Join(home, "argv")
|
||||
trace := filepath.Join(home, "must-not-trace")
|
||||
wrapper := "#!/bin/sh\nprintf '%s\\0' \"$@\" > \"$PBS_ARGV\"\nexec \"$PBS_CURL\" \"$@\"\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "curl"), []byte(wrapper), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(home, ".curlrc"), []byte("insecure\nlocation\ntrace = "+trace+"\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requests := make(chan string, 4)
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests <- strings.Join([]string{r.Method, r.URL.Path, r.Header.Get("Authorization")}, "\n")
|
||||
code := 200
|
||||
switch scenario {
|
||||
case "401":
|
||||
code = 401
|
||||
case "403":
|
||||
code = 403
|
||||
case "redirect":
|
||||
w.Header().Set("Location", "/must-not-follow")
|
||||
code = 307
|
||||
case "wrong success status":
|
||||
code = 206
|
||||
case "truncated installer":
|
||||
w.Header().Set("Content-Length", "1024")
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
server.Config.ErrorLog = log.New(io.Discard, "", 0)
|
||||
server.StartTLS()
|
||||
t.Cleanup(server.Close)
|
||||
originalURL := "https://pulse.example.com"
|
||||
if index == 1 {
|
||||
originalURL = "https://pbs.example.com:8007"
|
||||
}
|
||||
recipe = strings.Replace(recipe, originalURL, server.URL, 1)
|
||||
if scenario != "untrusted HTTPS" {
|
||||
ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw})
|
||||
if err := os.WriteFile(filepath.Join(home, "ca.pem"), ca, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recipe = strings.Replace(recipe, "curl --disable ", `curl --disable --cacert "$HOME/ca.pem" `, 1)
|
||||
}
|
||||
output, exit := runPBSRecipe(t, recipe, home, bin, "PBS_ARGV="+capture, "PBS_CURL="+realCurl)
|
||||
wantExit := 0
|
||||
switch scenario {
|
||||
case "401", "403":
|
||||
wantExit = 22
|
||||
case "untrusted HTTPS":
|
||||
wantExit = 60
|
||||
case "truncated installer":
|
||||
wantExit = 18
|
||||
case "redirect", "wrong success status", "existing installer", "symlinked header":
|
||||
wantExit = 1
|
||||
}
|
||||
if exit != wantExit {
|
||||
t.Fatalf("copied request exit %d, want %d: %s", exit, wantExit, output)
|
||||
}
|
||||
if strings.Contains(output, "synthetic-pbs-secret") || strings.Contains(output, "private-datastore-name") {
|
||||
t.Fatal("copied request disclosed a credential or response")
|
||||
}
|
||||
if _, err := os.Stat(trace); !os.IsNotExist(err) {
|
||||
t.Fatal("curl configuration enabled tracing")
|
||||
}
|
||||
if scenario == "existing installer" || scenario == "symlinked header" {
|
||||
if _, err := os.Stat(capture); !os.IsNotExist(err) || len(requests) != 0 {
|
||||
t.Fatal("unsafe local path reached curl")
|
||||
}
|
||||
path, expected := installer, "original-installer"
|
||||
if scenario == "symlinked header" {
|
||||
path, expected = filepath.Join(home, "outside-header"), "Authorization: "+token+"\n"
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil || string(content) != expected {
|
||||
t.Fatal("refused local path modified existing content")
|
||||
}
|
||||
return
|
||||
}
|
||||
argv, err := os.ReadFile(capture)
|
||||
if err != nil || strings.Contains(string(argv), "synthetic-pbs-secret") || !strings.HasPrefix(string(argv), "--disable\x00") {
|
||||
t.Fatal("curl arguments disclosed credentials or did not disable local configuration first")
|
||||
}
|
||||
if !strings.Contains(output, "HTTP ") {
|
||||
t.Fatal("request failed to expose its safe HTTP result")
|
||||
}
|
||||
if scenario == "untrusted HTTPS" {
|
||||
if len(requests) != 0 {
|
||||
t.Fatal("untrusted TLS peer received a request")
|
||||
}
|
||||
} else {
|
||||
if len(requests) != 1 {
|
||||
t.Fatal("request was missing, redirected or retried")
|
||||
}
|
||||
path, auth := "/install.sh", ""
|
||||
if index == 1 {
|
||||
path, auth = "/api2/json/admin/datastore", token
|
||||
}
|
||||
if got := <-requests; got != strings.Join([]string{"GET", path, auth}, "\n") {
|
||||
t.Fatal("copied request used the wrong method, endpoint or credential")
|
||||
}
|
||||
}
|
||||
if index == 0 {
|
||||
content, err := os.ReadFile(installer)
|
||||
if scenario == "success" {
|
||||
info, statErr := os.Stat(installer)
|
||||
if err != nil || string(content) != body || statErr != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatal("successful installer was not saved completely and privately")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatal("failed or redirected download became an executable installer path")
|
||||
}
|
||||
} else {
|
||||
responses, err := filepath.Glob(filepath.Join(dir, "pbs-response.??????"))
|
||||
if err != nil || len(responses) != 1 {
|
||||
t.Fatal("request did not retain one new private response")
|
||||
}
|
||||
info, err := os.Stat(responses[0])
|
||||
if err != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatal("response file is not private")
|
||||
}
|
||||
if scenario != "untrusted HTTPS" {
|
||||
content, err := os.ReadFile(responses[0])
|
||||
if err != nil || string(content) != body {
|
||||
t.Fatal("complete sensitive response was not retained locally")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user