mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Keep TrueNAS API setup credentials and responses private
Make the existing connection guide fail closed before editing credentials, ignore curl configuration, bound each single POST and retain sensitive responses in new private files. Explain actual test/save status and uncertain-save readback without retrying setup as diagnostics. Contract-Neutral: Documentation-only credential handling and copied-recipe regression tests; no runtime API, TrueNAS polling, authentication or connection-management contract changes. Change-source: pulse-maintainer
This commit is contained in:
+94
-28
@@ -101,48 +101,114 @@ All endpoints require admin authentication.
|
||||
|
||||
### Testing and adding a connection (API)
|
||||
|
||||
The **Settings → Infrastructure → Platform connections** flow is the simplest option. For an API client,
|
||||
prepare the private Pulse header file as described in the
|
||||
[API authentication guide](API.md). The Pulse API token authenticates to
|
||||
Pulse; the separate TrueNAS API key belongs in the connection's JSON file.
|
||||
Do not put either credential in shell arguments, URLs or a public thread.
|
||||
Prefer **Settings → Infrastructure → Platform connections** for setup. These
|
||||
API examples are for an authorised administrator using curl 7.76 or later,
|
||||
not a way to diagnose stale polling. Testing makes a live system-information
|
||||
request to TrueNAS; saving creates a connection and starts ordinary polling.
|
||||
Do not use either during a backup, freeze/thaw or an unresponsive-host incident.
|
||||
|
||||
Create or protect the connection file before opening it in a trusted editor:
|
||||
First prepare a private Pulse header file using the
|
||||
[API token file procedure](API.md#api-token-recommended), with the admin access
|
||||
needed for connection management. The Pulse token authenticates to Pulse;
|
||||
the separate TrueNAS API key belongs in the connection's JSON file. Enter
|
||||
credentials only in your local editor, never in arguments, URLs or a thread.
|
||||
|
||||
On a trusted machine, as the account running curl, prepare the connection
|
||||
file below. Stop if either file-preparation step fails. This preserves an
|
||||
existing file, restricts the directory and refuses symlinked credential paths
|
||||
or a non-regular connection file before opening the editor:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
mkdir -p "$HOME/.config/pulse"
|
||||
touch "$HOME/.config/pulse/truenas-connection.json"
|
||||
chmod 600 "$HOME/.config/pulse/truenas-connection.json"
|
||||
vi "$HOME/.config/pulse/truenas-connection.json"
|
||||
(
|
||||
set -eu
|
||||
umask 077
|
||||
config_dir="$HOME/.config/pulse"
|
||||
connection_file="$config_dir/truenas-connection.json"
|
||||
if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$connection_file" ]; then
|
||||
printf 'Refusing a symlinked credential path.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -e "$connection_file" ] && [ ! -f "$connection_file" ]; then
|
||||
printf 'Connection file must be a regular file.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$config_dir"
|
||||
chmod 700 "$config_dir"
|
||||
touch "$connection_file"
|
||||
chmod 600 "$connection_file"
|
||||
vi "$connection_file"
|
||||
)
|
||||
```
|
||||
|
||||
In that file, enter a JSON object with `name`, `host` (the TrueNAS HTTPS URL),
|
||||
`username` (the key owner) and `apiKey`. Keep both credential files private;
|
||||
do not paste their contents into a terminal command or upload them.
|
||||
In the editor, save a JSON object with `name`, `host` (the TrueNAS HTTPS URL),
|
||||
`username` (the key owner) and `apiKey`. Keep both credential files private and
|
||||
outside repositories and diagnostics. Do not upload them.
|
||||
|
||||
Test the connection before saving it:
|
||||
The following examples run **on the Pulse host**, using its direct loopback
|
||||
address. They ignore curl configuration, do not follow redirects or retry,
|
||||
and retain each response in a new private local file. Responses and errors can
|
||||
contain identifying details or echoed credentials; review them locally, not in
|
||||
a public terminal recording or thread. Keep `--disable` first so local curl
|
||||
configuration cannot enable tracing or change the request.
|
||||
|
||||
Test once without saving:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-binary "@$HOME/.config/pulse/truenas-connection.json" \
|
||||
http://127.0.0.1:7655/api/truenas/connections/test
|
||||
(
|
||||
set -eu
|
||||
umask 077
|
||||
result_file=$(mktemp "$HOME/.config/pulse/truenas-test.XXXXXX")
|
||||
printf 'Private response file: %s\n' "$result_file"
|
||||
status=$(curl --disable --fail-with-body --silent --show-error \
|
||||
--connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \
|
||||
--header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-binary "@$HOME/.config/pulse/truenas-connection.json" \
|
||||
--output "$result_file" --write-out '%{http_code}' \
|
||||
http://127.0.0.1:7655/api/truenas/connections/test)
|
||||
printf 'HTTP %s\n' "$status"
|
||||
[ "$status" = 200 ]
|
||||
)
|
||||
```
|
||||
|
||||
After checking the test response, save the connection with:
|
||||
Require exit 0, HTTP **200** and `success: true` in that private test response.
|
||||
A redirect, HTTP error or `000` is not a successful test. Test success is not
|
||||
inventory, metric or guest-thaw acceptance.
|
||||
|
||||
Only after reviewing the test response, create the connection once:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-binary "@$HOME/.config/pulse/truenas-connection.json" \
|
||||
http://127.0.0.1:7655/api/truenas/connections
|
||||
(
|
||||
set -eu
|
||||
umask 077
|
||||
result_file=$(mktemp "$HOME/.config/pulse/truenas-save.XXXXXX")
|
||||
printf 'Private response file: %s\n' "$result_file"
|
||||
status=$(curl --disable --fail-with-body --silent --show-error \
|
||||
--connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \
|
||||
--header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-binary "@$HOME/.config/pulse/truenas-connection.json" \
|
||||
--output "$result_file" --write-out '%{http_code}' \
|
||||
http://127.0.0.1:7655/api/truenas/connections)
|
||||
printf 'HTTP %s\n' "$status"
|
||||
[ "$status" = 201 ]
|
||||
)
|
||||
```
|
||||
|
||||
These Pulse URLs are loopback-only examples. For a remote Pulse server, use
|
||||
its trusted HTTPS origin without disabling certificate verification.
|
||||
A successful save returns HTTP **201** and the new connection's `id`, not
|
||||
proof of a complete poll. If saving times out or loses its response, it may
|
||||
already have created the connection. Check **Platform connections** or
|
||||
`/api/truenas/connections` in your existing signed-in Pulse browser before
|
||||
trying again; do not blindly repeat POST and create duplicates. Continue with
|
||||
the [ordinary polling checks](#stale-truenas-data) for collection acceptance.
|
||||
|
||||
For remote Pulse access, replace the loopback origin in the relevant command
|
||||
with your trusted **HTTPS** origin and change `--proto '=http'` to
|
||||
`--proto '=https'`. Keep certificate verification enabled. For a private CA,
|
||||
add `--cacert /path/to/trusted-ca.pem` using a CA obtained independently from
|
||||
your administrator, not from the failing connection. Do not add `--insecure`,
|
||||
`--location`, verbose/trace flags or automatic retries. Share only the relevant
|
||||
HTTP status and a manually redacted error, never the full response or headers.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -101,48 +101,114 @@ All endpoints require admin authentication.
|
||||
|
||||
### Testing and adding a connection (API)
|
||||
|
||||
The **Settings → Infrastructure → Platform connections** flow is the simplest option. For an API client,
|
||||
prepare the private Pulse header file as described in the
|
||||
[API authentication guide](API.md). The Pulse API token authenticates to
|
||||
Pulse; the separate TrueNAS API key belongs in the connection's JSON file.
|
||||
Do not put either credential in shell arguments, URLs or a public thread.
|
||||
Prefer **Settings → Infrastructure → Platform connections** for setup. These
|
||||
API examples are for an authorised administrator using curl 7.76 or later,
|
||||
not a way to diagnose stale polling. Testing makes a live system-information
|
||||
request to TrueNAS; saving creates a connection and starts ordinary polling.
|
||||
Do not use either during a backup, freeze/thaw or an unresponsive-host incident.
|
||||
|
||||
Create or protect the connection file before opening it in a trusted editor:
|
||||
First prepare a private Pulse header file using the
|
||||
[API token file procedure](API.md#api-token-recommended), with the admin access
|
||||
needed for connection management. The Pulse token authenticates to Pulse;
|
||||
the separate TrueNAS API key belongs in the connection's JSON file. Enter
|
||||
credentials only in your local editor, never in arguments, URLs or a thread.
|
||||
|
||||
On a trusted machine, as the account running curl, prepare the connection
|
||||
file below. Stop if either file-preparation step fails. This preserves an
|
||||
existing file, restricts the directory and refuses symlinked credential paths
|
||||
or a non-regular connection file before opening the editor:
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
mkdir -p "$HOME/.config/pulse"
|
||||
touch "$HOME/.config/pulse/truenas-connection.json"
|
||||
chmod 600 "$HOME/.config/pulse/truenas-connection.json"
|
||||
vi "$HOME/.config/pulse/truenas-connection.json"
|
||||
(
|
||||
set -eu
|
||||
umask 077
|
||||
config_dir="$HOME/.config/pulse"
|
||||
connection_file="$config_dir/truenas-connection.json"
|
||||
if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$connection_file" ]; then
|
||||
printf 'Refusing a symlinked credential path.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -e "$connection_file" ] && [ ! -f "$connection_file" ]; then
|
||||
printf 'Connection file must be a regular file.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$config_dir"
|
||||
chmod 700 "$config_dir"
|
||||
touch "$connection_file"
|
||||
chmod 600 "$connection_file"
|
||||
vi "$connection_file"
|
||||
)
|
||||
```
|
||||
|
||||
In that file, enter a JSON object with `name`, `host` (the TrueNAS HTTPS URL),
|
||||
`username` (the key owner) and `apiKey`. Keep both credential files private;
|
||||
do not paste their contents into a terminal command or upload them.
|
||||
In the editor, save a JSON object with `name`, `host` (the TrueNAS HTTPS URL),
|
||||
`username` (the key owner) and `apiKey`. Keep both credential files private and
|
||||
outside repositories and diagnostics. Do not upload them.
|
||||
|
||||
Test the connection before saving it:
|
||||
The following examples run **on the Pulse host**, using its direct loopback
|
||||
address. They ignore curl configuration, do not follow redirects or retry,
|
||||
and retain each response in a new private local file. Responses and errors can
|
||||
contain identifying details or echoed credentials; review them locally, not in
|
||||
a public terminal recording or thread. Keep `--disable` first so local curl
|
||||
configuration cannot enable tracing or change the request.
|
||||
|
||||
Test once without saving:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-binary "@$HOME/.config/pulse/truenas-connection.json" \
|
||||
http://127.0.0.1:7655/api/truenas/connections/test
|
||||
(
|
||||
set -eu
|
||||
umask 077
|
||||
result_file=$(mktemp "$HOME/.config/pulse/truenas-test.XXXXXX")
|
||||
printf 'Private response file: %s\n' "$result_file"
|
||||
status=$(curl --disable --fail-with-body --silent --show-error \
|
||||
--connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \
|
||||
--header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-binary "@$HOME/.config/pulse/truenas-connection.json" \
|
||||
--output "$result_file" --write-out '%{http_code}' \
|
||||
http://127.0.0.1:7655/api/truenas/connections/test)
|
||||
printf 'HTTP %s\n' "$status"
|
||||
[ "$status" = 200 ]
|
||||
)
|
||||
```
|
||||
|
||||
After checking the test response, save the connection with:
|
||||
Require exit 0, HTTP **200** and `success: true` in that private test response.
|
||||
A redirect, HTTP error or `000` is not a successful test. Test success is not
|
||||
inventory, metric or guest-thaw acceptance.
|
||||
|
||||
Only after reviewing the test response, create the connection once:
|
||||
|
||||
```bash
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-binary "@$HOME/.config/pulse/truenas-connection.json" \
|
||||
http://127.0.0.1:7655/api/truenas/connections
|
||||
(
|
||||
set -eu
|
||||
umask 077
|
||||
result_file=$(mktemp "$HOME/.config/pulse/truenas-save.XXXXXX")
|
||||
printf 'Private response file: %s\n' "$result_file"
|
||||
status=$(curl --disable --fail-with-body --silent --show-error \
|
||||
--connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \
|
||||
--header "@$HOME/.config/pulse/api-header" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-binary "@$HOME/.config/pulse/truenas-connection.json" \
|
||||
--output "$result_file" --write-out '%{http_code}' \
|
||||
http://127.0.0.1:7655/api/truenas/connections)
|
||||
printf 'HTTP %s\n' "$status"
|
||||
[ "$status" = 201 ]
|
||||
)
|
||||
```
|
||||
|
||||
These Pulse URLs are loopback-only examples. For a remote Pulse server, use
|
||||
its trusted HTTPS origin without disabling certificate verification.
|
||||
A successful save returns HTTP **201** and the new connection's `id`, not
|
||||
proof of a complete poll. If saving times out or loses its response, it may
|
||||
already have created the connection. Check **Platform connections** or
|
||||
`/api/truenas/connections` in your existing signed-in Pulse browser before
|
||||
trying again; do not blindly repeat POST and create duplicates. Continue with
|
||||
the [ordinary polling checks](#stale-truenas-data) for collection acceptance.
|
||||
|
||||
For remote Pulse access, replace the loopback origin in the relevant command
|
||||
with your trusted **HTTPS** origin and change `--proto '=http'` to
|
||||
`--proto '=https'`. Keep certificate verification enabled. For a private CA,
|
||||
add `--cacert /path/to/trusted-ca.pem` using a CA obtained independently from
|
||||
your administrator, not from the failing connection. Do not add `--insecure`,
|
||||
`--location`, verbose/trace flags or automatic retries. Share only the relevant
|
||||
HTTP status and a manually redacted error, never the full response or headers.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
package installtests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func trueNASAPIBlocks(doc string) ([]string, error) {
|
||||
_, section, found := strings.Cut(doc, "### Testing and adding a connection (API)\n")
|
||||
if !found {
|
||||
return nil, fmt.Errorf("connection API guidance is missing")
|
||||
}
|
||||
section, _, _ = strings.Cut(section, "\n## Troubleshooting")
|
||||
matches := regexp.MustCompile("(?s)```bash\n(.*?)```").FindAllStringSubmatch(section, -1)
|
||||
if len(matches) != 3 {
|
||||
return nil, fmt.Errorf("want separate preparation/test/save blocks, got %d", len(matches))
|
||||
}
|
||||
blocks := make([]string, 3)
|
||||
for i, match := range matches {
|
||||
blocks[i] = match[1]
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func trueNASAPISafety(blocks []string) error {
|
||||
for _, required := range []string{"set -eu", "umask 077", `[ -L "$HOME/.config" ]`,
|
||||
`[ -L "$config_dir" ]`, `[ -L "$connection_file" ]`, `[ ! -f "$connection_file" ]`,
|
||||
`chmod 700 "$config_dir"`, `chmod 600 "$connection_file"`, `vi "$connection_file"`} {
|
||||
if !strings.Contains(blocks[0], required) {
|
||||
return fmt.Errorf("unsafe file preparation: missing %s", required)
|
||||
}
|
||||
}
|
||||
for i, block := range blocks[1:] {
|
||||
for _, required := range []string{"set -eu", "umask 077", `result_file=$(mktemp "$HOME/.config/pulse/truenas-`,
|
||||
"status=$(curl --disable --fail-with-body --silent --show-error", "--connect-timeout 5 --max-time 20",
|
||||
"--proto '=http' --noproxy 127.0.0.1", `--header "@$HOME/.config/pulse/api-header"`,
|
||||
`--data-binary "@$HOME/.config/pulse/truenas-connection.json"`,
|
||||
`--output "$result_file" --write-out '%{http_code}'`,
|
||||
fmt.Sprintf(`[ "$status" = %d ]`, 200+i)} {
|
||||
if !strings.Contains(block, required) {
|
||||
return fmt.Errorf("unsafe request %d: missing %s", i+1, required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"--insecure", "--location", "--trace", "--verbose", "--config", "--retry", "X-API-Token:", "Authorization:"} {
|
||||
if strings.Contains(block, forbidden) {
|
||||
return fmt.Errorf("unsafe request: %s", forbidden)
|
||||
}
|
||||
}
|
||||
if strings.Count(block, "curl ") != 1 || strings.Contains(block, " | ") {
|
||||
return fmt.Errorf("each copied request must retain its own result and make only one POST")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func trueNASAPIGuidance(t *testing.T) []string {
|
||||
t.Helper()
|
||||
doc, err := os.ReadFile(repoFile("docs", "TRUENAS.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocks, err := trueNASAPIBlocks(string(doc))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := trueNASAPISafety(blocks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mirror, err := os.ReadFile(repoFile("frontend-modern", "public", "docs", "TRUENAS.md"))
|
||||
if err != nil || string(mirror) != string(doc) {
|
||||
t.Fatal("shipped TrueNAS guide must be identical to the verified recipe")
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func runTrueNASRecipe(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 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 TestTrueNASAPIGuidancePreparationIsPrivateAndFailClosed(t *testing.T) {
|
||||
for _, mode := range []string{"new", "existing", "config symlink", "directory symlink", "file symlink", "non-regular", "mkdir failure", "chmod failure"} {
|
||||
t.Run(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")
|
||||
if err := os.WriteFile(filepath.Join(bin, "vi"), []byte("#!/bin/sh\nprintf 'started' > \"$EDITOR_STARTED\"\n"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join(home, ".config", "pulse")
|
||||
file := filepath.Join(dir, "truenas-connection.json")
|
||||
if mode == "existing" || mode == "file 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
|
||||
if mode == "file symlink" {
|
||||
if err := os.WriteFile(target, []byte("private-existing-key"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
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("private-existing-key"), 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") {
|
||||
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 := runTrueNASRecipe(t, trueNASAPIGuidance(t)[0], home, bin, "EDITOR_STARTED="+edited)
|
||||
_, editorErr := os.Stat(edited)
|
||||
if mode != "new" && mode != "existing" {
|
||||
if exit == 0 || !os.IsNotExist(editorErr) {
|
||||
t.Fatal("unsafe or failed preparation reached the editor")
|
||||
}
|
||||
if strings.Contains(mode, "symlink") {
|
||||
info, err := os.Stat(target)
|
||||
if err != nil || info.Mode().Perm() != map[bool]os.FileMode{true: 0644, false: 0755}[mode == "file symlink"] {
|
||||
t.Fatal("refused path modified its 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 has incorrect permissions")
|
||||
}
|
||||
}
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil || (mode == "existing" && string(content) != "private-existing-key") {
|
||||
t.Fatal("preparation did not preserve the existing credential file")
|
||||
}
|
||||
}
|
||||
if strings.Contains(output, "private-existing-key") {
|
||||
t.Fatal("preparation exposed private contents")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrueNASAPIGuidanceRejectsUnsafeRecipesAndExactParent(t *testing.T) {
|
||||
blocks := trueNASAPIGuidance(t)
|
||||
for name, edit := range map[string][2]string{
|
||||
"config": {"curl --disable ", "curl "}, "body": {`--output "$result_file" `, ""},
|
||||
"deadline": {"--max-time 20 ", ""}, "retry": {"curl --disable ", "curl --disable --retry 2 "},
|
||||
"redirect": {"curl --disable ", "curl --disable --location "},
|
||||
"inline token": {`"@$HOME/.config/pulse/api-header"`, `'X-API-Token: synthetic-secret'`},
|
||||
"fail-open": {"set -eu", ""}, "wrong save status": {`[ "$status" = 201 ]`, `[ "$status" = 200 ]`},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
changed := make([]string, len(blocks))
|
||||
for i, block := range blocks {
|
||||
changed[i] = strings.ReplaceAll(block, edit[0], edit[1])
|
||||
}
|
||||
if trueNASAPISafety(changed) == nil {
|
||||
t.Fatal("unsafe mutation was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if inputs := os.Getenv("PULSE_PROOF_INPUTS"); inputs != "" {
|
||||
parent, err := os.ReadFile(filepath.Join(inputs, "truenas-parent.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old, err := trueNASAPIBlocks(string(parent))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := trueNASAPISafety(old); err == nil {
|
||||
t.Fatal("unchanged exact parent guide passed new safety controls")
|
||||
} else {
|
||||
t.Logf("exact parent rejected: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Synthetic peers exercise the actual copied commands and deliberately echo
|
||||
// both fixture secrets. This is credential/transport safety, not native
|
||||
// TrueNAS collection, Pulse authorisation or a customer's setup acceptance.
|
||||
func TestTrueNASAPIGuidanceCopiedRequestsKeepSecretsPrivate(t *testing.T) {
|
||||
for _, scenario := range []string{"success", "HTTP error", "redirect", "wrong success status", "trusted HTTPS", "untrusted HTTPS"} {
|
||||
for index, recipe := range trueNASAPIGuidance(t)[1:] {
|
||||
t.Run(fmt.Sprintf("%s/%d", scenario, index+1), func(t *testing.T) {
|
||||
realCurl, err := exec.LookPath("curl")
|
||||
if err != nil {
|
||||
t.Fatal("curl is required to verify copied TrueNAS 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 = "synthetic-pulse-secret"
|
||||
const key = "synthetic-truenas-secret"
|
||||
const body = `{"host":"https://synthetic.invalid","apiKey":"` + key + `"}`
|
||||
for file, content := range map[string]string{"api-header": "X-API-Token: " + token, "truenas-connection.json": body} {
|
||||
if err := os.WriteFile(filepath.Join(dir, file), []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
capture := filepath.Join(home, "argv")
|
||||
trace := filepath.Join(home, "must-not-trace")
|
||||
wrapper := "#!/bin/sh\nprintf '%s\\0' \"$@\" > \"$TN_ARGV\"\nexec \"$TN_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) {
|
||||
payload, _ := io.ReadAll(r.Body)
|
||||
requests <- strings.Join([]string{r.Method, r.URL.Path, r.Header.Get("X-API-Token"), r.Header.Get("Content-Type"), string(payload)}, "\n")
|
||||
code := 200 + index
|
||||
switch scenario {
|
||||
case "HTTP error":
|
||||
code = 400
|
||||
case "redirect":
|
||||
w.Header().Set("Location", "/must-not-follow")
|
||||
code = 307
|
||||
case "wrong success status":
|
||||
code = 201 - index
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
fmt.Fprint(w, token+" "+key+" private-response")
|
||||
}))
|
||||
server.Config.ErrorLog = log.New(io.Discard, "", 0)
|
||||
if strings.Contains(scenario, "HTTPS") {
|
||||
server.StartTLS()
|
||||
recipe = strings.Replace(recipe, "--proto '=http'", "--proto '=https'", 1)
|
||||
if scenario == "trusted 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)
|
||||
}
|
||||
} else {
|
||||
server.Start()
|
||||
}
|
||||
t.Cleanup(server.Close)
|
||||
recipe = strings.Replace(recipe, "http://127.0.0.1:7655", server.URL, 1)
|
||||
output, exit := runTrueNASRecipe(t, recipe, home, bin, "TN_ARGV="+capture, "TN_CURL="+realCurl)
|
||||
wantExit := 0
|
||||
if scenario == "HTTP error" {
|
||||
wantExit = 22
|
||||
} else if scenario == "untrusted HTTPS" {
|
||||
wantExit = 60
|
||||
} else if scenario == "redirect" || scenario == "wrong success status" {
|
||||
wantExit = 1
|
||||
}
|
||||
if exit != wantExit {
|
||||
t.Fatalf("copied request exit %d, want %d: %s", exit, wantExit, output)
|
||||
}
|
||||
argv, err := os.ReadFile(capture)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, secret := range []string{token, key, "private-response"} {
|
||||
if strings.Contains(string(argv), secret) || strings.Contains(output, secret) {
|
||||
t.Fatal("copied request exposed a fixture credential or response")
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(trace); !os.IsNotExist(err) {
|
||||
t.Fatal("curl configuration enabled tracing")
|
||||
}
|
||||
responses, err := filepath.Glob(filepath.Join(dir, "truenas-*.??????"))
|
||||
if err != nil || len(responses) != 1 {
|
||||
t.Fatal("request must retain one new private response file")
|
||||
}
|
||||
info, err := os.Stat(responses[0])
|
||||
if err != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatal("response file is not private")
|
||||
}
|
||||
if scenario == "untrusted HTTPS" {
|
||||
if len(requests) != 0 {
|
||||
t.Fatal("untrusted TLS peer received credentials")
|
||||
}
|
||||
} else {
|
||||
if len(requests) != 1 {
|
||||
t.Fatal("request was missing, redirected or retried")
|
||||
}
|
||||
path := "/api/truenas/connections"
|
||||
if index == 0 {
|
||||
path += "/test"
|
||||
}
|
||||
if got := <-requests; got != strings.Join([]string{"POST", path, token, "application/json", body}, "\n") {
|
||||
t.Fatal("copied POST differs from the documented endpoint, header or JSON file")
|
||||
}
|
||||
response, err := os.ReadFile(responses[0])
|
||||
if err != nil || string(response) != token+" "+key+" private-response" {
|
||||
t.Fatal("complete sensitive response was not retained locally")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user