Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
52efa9b
storage/cloud: avoid panic when waitForRetry is unset
tis24dev Mar 19, 2026
fbd9aa9
storage: normalize bundle inputs before building bundle and legacy si…
tis24dev Mar 19, 2026
5d049bb
orchestrator: isolate HA restore subtests from shared global state
tis24dev Mar 19, 2026
323abc1
orchestrator: guard decrypt workflow UI entry points against typed-ni…
tis24dev Mar 19, 2026
c2cd1cf
orchestrator: make decrypt TUI e2e key injection screen-driven instea…
tis24dev Mar 19, 2026
5cdbf7b
notify/webhook: stabilize retry cancellation test with atomic attempt…
tis24dev Mar 19, 2026
fe4240b
config: quote unsafe secondary path values when mutating backup.env
tis24dev Mar 19, 2026
e1beabc
config: assert exact final env values in env mutation tests
tis24dev Mar 19, 2026
97a04c0
config: make setBaseDirEnv unset BASE_DIR when empty
tis24dev Mar 19, 2026
da617e0
cmd/proxsave: normalize preserved entry slashes in new install output
tis24dev Mar 19, 2026
f8d5b60
cmd/proxsave: remove unused clearImmutableAttributes wrapper
tis24dev Mar 19, 2026
3e3884b
cmd/proxsave: respect context cancellation when resolving existing co…
tis24dev Mar 19, 2026
0a1d889
orchestrator: remove redundant stdout cleanup in CLI workflow test he…
tis24dev Mar 19, 2026
c8a7044
config: derive invalid migration fixture from base install template
tis24dev Mar 19, 2026
fbd1f20
orchestrator: assert context forwarding in restore TUI confirmation t…
tis24dev Mar 19, 2026
7706577
orchestrator: write CLI path validation errors to stderr
tis24dev Mar 19, 2026
b74c625
orchestrator: synchronize restore TUI countdown shutdown before readi…
tis24dev Mar 19, 2026
a098750
identity: always relock identity file on canceled or failed writes
tis24dev Mar 19, 2026
2bf4e33
fix(notify): validate cloud relay response body on HTTP 200
tis24dev Mar 22, 2026
4f184cc
Improve relay error diagnostics and worker IP whitelist docs
tis24dev Mar 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions cmd/proxsave/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -838,12 +838,6 @@ func isInstallAbortedError(err error) bool {
return false
}

// clearImmutableAttributes attempts to remove immutable flags (chattr -i) so deletion can proceed.
// It logs warnings on failure but does not return an error, since removal will report issues later.
func clearImmutableAttributes(target string, bootstrap *logging.BootstrapLogger) {
_ = clearImmutableAttributesWithContext(context.Background(), target, bootstrap)
}

func clearImmutableAttributesWithContext(ctx context.Context, target string, bootstrap *logging.BootstrapLogger) error {
if ctx == nil {
ctx = context.Background()
Expand Down
19 changes: 19 additions & 0 deletions cmd/proxsave/install_existing_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,16 @@ type existingConfigDecision struct {
}

func promptExistingConfigModeCLI(ctx context.Context, reader *bufio.Reader, configPath string) (existingConfigMode, error) {
if ctx == nil {
ctx = context.Background()
}

info, err := os.Stat(configPath)
if err != nil {
if os.IsNotExist(err) {
if err := ctx.Err(); err != nil {
return existingConfigCancel, err
}
return existingConfigOverwrite, nil
}
return existingConfigCancel, fmt.Errorf("failed to access configuration file: %w", err)
Expand All @@ -53,12 +60,24 @@ func promptExistingConfigModeCLI(ctx context.Context, reader *bufio.Reader, conf
case "":
fallthrough
case "3":
if err := ctx.Err(); err != nil {
return existingConfigCancel, err
}
return existingConfigKeepContinue, nil
case "1":
if err := ctx.Err(); err != nil {
return existingConfigCancel, err
}
return existingConfigOverwrite, nil
case "2":
if err := ctx.Err(); err != nil {
return existingConfigCancel, err
}
return existingConfigEdit, nil
case "0":
if err := ctx.Err(); err != nil {
return existingConfigCancel, err
}
return existingConfigCancel, nil
default:
fmt.Println("Please enter 1, 2, 3 or 0.")
Expand Down
14 changes: 14 additions & 0 deletions cmd/proxsave/install_existing_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ func TestPromptExistingConfigModeCLIMissingFileDefaultsToOverwrite(t *testing.T)
}
}

func TestPromptExistingConfigModeCLIMissingFileRespectsCanceledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()

missing := filepath.Join(t.TempDir(), "missing.env")
mode, err := promptExistingConfigModeCLI(ctx, bufio.NewReader(strings.NewReader("")), missing)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context canceled error, got %v", err)
}
if mode != existingConfigCancel {
t.Fatalf("expected cancel mode, got %v", mode)
}
}

func TestPromptExistingConfigModeCLIOptions(t *testing.T) {
cfgFile := createTempFile(t, "EXISTING=1\n")
tests := []struct {
Expand Down
1 change: 1 addition & 0 deletions cmd/proxsave/new_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func formatNewInstallPreservedEntries(entries []string) string {
formatted := make([]string, 0, len(entries))
for _, entry := range entries {
trimmed := strings.TrimSpace(entry)
trimmed = strings.TrimRight(trimmed, "/")
if trimmed == "" {
continue
}
Expand Down
5 changes: 5 additions & 0 deletions cmd/proxsave/new_install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ func TestFormatNewInstallPreservedEntries(t *testing.T) {
entries: []string{"", " ", "\t"},
want: "(none)",
},
{
name: "normalizes trailing slashes",
entries: []string{"env/", "build//", " identity/// ", "/"},
want: "env/ build/ identity/",
},
}

for _, tt := range tests {
Expand Down
1 change: 1 addition & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,7 @@ If `EMAIL_ENABLED` is omitted, the default remains `false`. The legacy alias `EM
- Allowed values for `EMAIL_DELIVERY_METHOD` are: `relay`, `sendmail`, `pmf` (invalid values will skip Email with a warning).
- `EMAIL_FALLBACK_SENDMAIL` is a historical name (kept for compatibility). When `EMAIL_DELIVERY_METHOD=relay`, it enables fallback to **pmf** (it will not fall back to `/usr/sbin/sendmail`).
- `relay` requires a real mailbox recipient and blocks `root@…` recipients; set `EMAIL_RECIPIENT` to a non-root mailbox if needed.
- When logs say the relay "accepted request", it means the worker and upstream email API accepted the submission. It does **not** guarantee final inbox delivery (the message may still bounce, be deferred, or land in spam later).
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Soften the guarantee in this sentence.

Line 849 is a bit stronger than the current implementation. sendViaCloudRelay still treats some HTTP 200 responses with empty or unparsable bodies as success, so this log line does not always prove the upstream provider accepted the message. Wording this as “the relay accepted the request / returned HTTP 200” would be safer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/CONFIGURATION.md` at line 849, The log sentence overstates guarantee;
update the wording to reflect that "accepted" refers to the relay returning an
HTTP 200-style response rather than guaranteed delivery—change the line to
something like "When logs say the relay 'accepted request' or 'returned HTTP
200', it means sendViaCloudRelay (and the upstream email API) returned a success
response; it does not guarantee final inbox delivery (the message may still
bounce, be deferred, or land in spam later)." Ensure the change references
sendViaCloudRelay to align wording with its current behavior when it treats
empty or unparsable 200 responses as success.

- If `EMAIL_RECIPIENT` is empty, ProxSave auto-detects the recipient from the `root@pam` user:
- **PVE**: Proxmox API via `pvesh get /access/users/root@pam` → fallback to `pveum user list` → fallback to `/etc/pve/user.cfg`
- **PBS**: `proxmox-backup-manager user list` → fallback to `/etc/proxmox-backup/user.cfg`
Expand Down
16 changes: 16 additions & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,22 @@ If Email is enabled but you don't see it being dispatched, ensure `EMAIL_DELIVER
- Relay blocks `root@…` recipients; use a real non-root mailbox for `EMAIL_RECIPIENT`.
- If `EMAIL_FALLBACK_SENDMAIL=true`, ProxSave will fall back to `EMAIL_DELIVERY_METHOD=pmf` when the relay fails.
- Check the proxsave logs for `email-relay` warnings/errors.
- `Email relay accepted request ...` means the relay accepted the submission. It does **not** guarantee final inbox delivery; later provider-side failures/bounces are outside the ProxSave process.

Common relay auth/forbidden errors:

- `authentication failed (HTTP 401): missing bearer token`: the relay did not receive the `Authorization: Bearer ...` header.
- `authentication failed (HTTP 401): missing signature`: the relay did not receive the `X-Signature` header.
- `forbidden (HTTP 403): invalid token`: the bearer token is wrong or not allowed by the worker.
- `forbidden (HTTP 403): HMAC signature validation failed`: the request body and `X-Signature` do not match the worker's `HMAC_SECRET`.
- `forbidden (HTTP 403): missing or invalid script version`: the relay rejected `X-Script-Version` (it must be semantic-version-like, e.g. `1.2.3`).
- `forbidden (HTTP 403): from address override not allowed`: the client attempted to override the worker-managed sender address.

If you operate your own relay worker:

- The worker-side env var `MAC_LIMIT_IP_WHITELIST` can bypass the per-server daily MAC quota for trusted source IPs.
- Example: `MAC_LIMIT_IP_WHITELIST=86.56.17.99`
- This bypass affects only the daily MAC quota. It does **not** disable bearer-token checks, HMAC validation, IP burst limits, or token limits.

Quick checks for auto-detect:

Expand Down
18 changes: 18 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,24 @@ import (

func setBaseDirEnv(t *testing.T, value string) {
t.Helper()
if value == "" {
original, hadOriginal := os.LookupEnv("BASE_DIR")
if err := os.Unsetenv("BASE_DIR"); err != nil {
t.Fatalf("Unsetenv(BASE_DIR) failed: %v", err)
}
t.Cleanup(func() {
if hadOriginal {
if err := os.Setenv("BASE_DIR", original); err != nil {
t.Fatalf("restore BASE_DIR failed: %v", err)
}
return
}
if err := os.Unsetenv("BASE_DIR"); err != nil {
t.Fatalf("cleanup Unsetenv(BASE_DIR) failed: %v", err)
}
})
return
}
t.Setenv("BASE_DIR", value)
}

Expand Down
23 changes: 21 additions & 2 deletions internal/config/env_mutation.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,38 @@
package config

import (
"strconv"
"strings"

"github.com/tis24dev/proxsave/pkg/utils"
)

func encodeEnvValue(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}

if !strings.ContainsAny(value, "# \t\r\n\"'") {
return value
}
if !strings.Contains(value, "'") {
return "'" + value + "'"
}
if !strings.Contains(value, `"`) {
return `"` + value + `"`
}
return strconv.Quote(value)
Comment on lines +19 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if TrimQuotes handles strconv.Quote escape sequences

# Find TrimQuotes implementation
rg -n -A10 'func TrimQuotes' pkg/utils/

# Check if there's any strconv.Unquote usage in parsing
rg -n 'strconv\.Unquote' internal/config/ pkg/utils/

Repository: tis24dev/proxsave

Length of output: 615


🏁 Script executed:

# Find parseEnvFile and see how it processes values
rg -n -A20 'func parseEnvFile' internal/config/

# Check how encodeEnvValue results are used
rg -n -B5 -A5 'encodeEnvValue' internal/config/

# Look for any unquoting logic after parsing
rg -n 'TrimQuotes' internal/config/ pkg/

Repository: tis24dev/proxsave

Length of output: 3319


🏁 Script executed:

# Get the complete parseEnvFile function
sed -n '1329,1400p' internal/config/config.go

# Get SplitKeyValue implementation
rg -n -A15 'func SplitKeyValue' pkg/utils/

# Check the full context of line 116 in strings.go
sed -n '110,125p' pkg/utils/strings.go

Repository: tis24dev/proxsave

Length of output: 3276


🏁 Script executed:

# Check where SECONDARY_PATH and SECONDARY_LOG_PATH are used
rg -n 'SECONDARY_PATH|SECONDARY_LOG_PATH' internal/config/

# Check for test cases that might trigger strconv.Quote path
rg -n 'encodeEnvValue' --include='*_test.go'

# Look for any tests with paths containing both quotes
rg -n "both.*quote|quote.*both" pkg/utils/ internal/config/

Repository: tis24dev/proxsave

Length of output: 7685


🏁 Script executed:

# Search for tests that might have paths with both quotes
rg -n "both.*quote|'.*\"|\"\.*'" pkg/utils/ internal/config/ --type go

# Check env_mutation_test.go for all test cases
sed -n '1,120p' internal/config/env_mutation_test.go

Repository: tis24dev/proxsave

Length of output: 5474


Round-trip correctness issue with strconv.Quote fallback.

When a path contains both single and double quotes, encodeEnvValue uses strconv.Quote (line 25), producing Go-style escape sequences like "path'with\"both". However, TrimQuotes in pkg/utils/strings.go only strips outer quotes without calling strconv.Unquote, so values read back from the file via parseEnvFile will be corrupted (e.g., path'with\"both instead of path'with"both).

The round-trip test (TestApplySecondaryStorageSettingsQuotesUnsafePaths) currently only covers paths with spaces/special characters that use the single-quote path, not the strconv.Quote fallback. A path with both quote types would silently fail.

Consider updating TrimQuotes to use strconv.Unquote for double-quoted values, or avoid the strconv.Quote path by escaping single quotes within single-quoted strings using the shell convention ('\'').

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/config/env_mutation.go` around lines 19 - 25, encodeEnvValue
currently falls back to strconv.Quote when the value contains both single and
double quotes, but TrimQuotes (used by parseEnvFile) only strips outer quotes
and doesn't unescape Go-style quoted strings, breaking round-trip correctness;
either (A) update TrimQuotes in pkg/utils/strings.go to detect double-quoted
values and call strconv.Unquote to properly unescape Go-style escapes (ensuring
errors are handled), or (B) change encodeEnvValue to never use strconv.Quote by
emitting a single-quoted shell-safe form that escapes internal single quotes
using the shell convention ('\''), so parseEnvFile/TrimQuotes can continue to
just strip outer single quotes — pick one approach and implement it consistently
across encodeEnvValue, TrimQuotes, and parseEnvFile to preserve round-trip
integrity.

}

// ApplySecondaryStorageSettings writes the canonical secondary-storage state
// into an env template. Disabled secondary storage always clears both
// SECONDARY_PATH and SECONDARY_LOG_PATH so the saved config matches user intent.
func ApplySecondaryStorageSettings(template string, enabled bool, secondaryPath string, secondaryLogPath string) string {
if enabled {
template = utils.SetEnvValue(template, "SECONDARY_ENABLED", "true")
template = utils.SetEnvValue(template, "SECONDARY_PATH", strings.TrimSpace(secondaryPath))
template = utils.SetEnvValue(template, "SECONDARY_LOG_PATH", strings.TrimSpace(secondaryLogPath))
template = utils.SetEnvValue(template, "SECONDARY_PATH", encodeEnvValue(secondaryPath))
template = utils.SetEnvValue(template, "SECONDARY_LOG_PATH", encodeEnvValue(secondaryLogPath))
return template
}

Expand Down
122 changes: 80 additions & 42 deletions internal/config/env_mutation_test.go
Original file line number Diff line number Diff line change
@@ -1,74 +1,112 @@
package config

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestApplySecondaryStorageSettingsEnabled(t *testing.T) {
template := "SECONDARY_ENABLED=false\nSECONDARY_PATH=\nSECONDARY_LOG_PATH=\n"
func parseMutatedEnvTemplate(t *testing.T, template string) (map[string]string, map[string]int) {
t.Helper()

got := ApplySecondaryStorageSettings(template, true, " /mnt/secondary ", " /mnt/secondary/log ")
values := make(map[string]string)
counts := make(map[string]int)

for _, line := range strings.Split(template, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}

for _, needle := range []string{
"SECONDARY_ENABLED=true",
"SECONDARY_PATH=/mnt/secondary",
"SECONDARY_LOG_PATH=/mnt/secondary/log",
} {
if !strings.Contains(got, needle) {
t.Fatalf("expected %q in template:\n%s", needle, got)
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
t.Fatalf("invalid env line %q in template:\n%s", line, template)
}

key := strings.TrimSpace(parts[0])
value := parts[1]
counts[key]++
values[key] = value
}

return values, counts
}

func assertMutatedEnvValue(t *testing.T, values map[string]string, counts map[string]int, key, want string) {
t.Helper()

if got := counts[key]; got != 1 {
t.Fatalf("%s occurrences = %d; want 1", key, got)
}
if got := values[key]; got != want {
t.Fatalf("%s = %q; want %q", key, got, want)
}
}

func TestApplySecondaryStorageSettingsEnabled(t *testing.T) {
template := "SECONDARY_ENABLED=false\nSECONDARY_PATH=\nSECONDARY_LOG_PATH=\n"

got := ApplySecondaryStorageSettings(template, true, " /mnt/secondary ", " /mnt/secondary/log ")
values, counts := parseMutatedEnvTemplate(t, got)
assertMutatedEnvValue(t, values, counts, "SECONDARY_ENABLED", "true")
assertMutatedEnvValue(t, values, counts, "SECONDARY_PATH", "/mnt/secondary")
assertMutatedEnvValue(t, values, counts, "SECONDARY_LOG_PATH", "/mnt/secondary/log")
}

func TestApplySecondaryStorageSettingsEnabledWithEmptyLogPath(t *testing.T) {
template := "SECONDARY_ENABLED=false\nSECONDARY_PATH=\nSECONDARY_LOG_PATH=/old/log\n"

got := ApplySecondaryStorageSettings(template, true, "/mnt/secondary", "")

for _, needle := range []string{
"SECONDARY_ENABLED=true",
"SECONDARY_PATH=/mnt/secondary",
"SECONDARY_LOG_PATH=",
} {
if !strings.Contains(got, needle) {
t.Fatalf("expected %q in template:\n%s", needle, got)
}
}
values, counts := parseMutatedEnvTemplate(t, got)
assertMutatedEnvValue(t, values, counts, "SECONDARY_ENABLED", "true")
assertMutatedEnvValue(t, values, counts, "SECONDARY_PATH", "/mnt/secondary")
assertMutatedEnvValue(t, values, counts, "SECONDARY_LOG_PATH", "")
}

func TestApplySecondaryStorageSettingsDisabledClearsValues(t *testing.T) {
template := "SECONDARY_ENABLED=true\nSECONDARY_PATH=/mnt/old-secondary\nSECONDARY_LOG_PATH=/mnt/old-secondary/logs\n"

got := ApplySecondaryStorageSettings(template, false, "/ignored", "/ignored/logs")

for _, needle := range []string{
"SECONDARY_ENABLED=false",
"SECONDARY_PATH=",
"SECONDARY_LOG_PATH=",
} {
if !strings.Contains(got, needle) {
t.Fatalf("expected %q in template:\n%s", needle, got)
}
}
if strings.Contains(got, "/mnt/old-secondary") {
t.Fatalf("expected old secondary values to be cleared:\n%s", got)
}
values, counts := parseMutatedEnvTemplate(t, got)
assertMutatedEnvValue(t, values, counts, "SECONDARY_ENABLED", "false")
assertMutatedEnvValue(t, values, counts, "SECONDARY_PATH", "")
assertMutatedEnvValue(t, values, counts, "SECONDARY_LOG_PATH", "")
}

func TestApplySecondaryStorageSettingsDisabledAppendsCanonicalState(t *testing.T) {
template := "BACKUP_ENABLED=true\n"

got := ApplySecondaryStorageSettings(template, false, "", "")
values, counts := parseMutatedEnvTemplate(t, got)
assertMutatedEnvValue(t, values, counts, "BACKUP_ENABLED", "true")
assertMutatedEnvValue(t, values, counts, "SECONDARY_ENABLED", "false")
assertMutatedEnvValue(t, values, counts, "SECONDARY_PATH", "")
assertMutatedEnvValue(t, values, counts, "SECONDARY_LOG_PATH", "")
}

for _, needle := range []string{
"BACKUP_ENABLED=true",
"SECONDARY_ENABLED=false",
"SECONDARY_PATH=",
"SECONDARY_LOG_PATH=",
} {
if !strings.Contains(got, needle) {
t.Fatalf("expected %q in template:\n%s", needle, got)
}
func TestApplySecondaryStorageSettingsQuotesUnsafePaths(t *testing.T) {
template := "SECONDARY_ENABLED=false\nSECONDARY_PATH=\nSECONDARY_LOG_PATH=\n"

got := ApplySecondaryStorageSettings(template, true, " /mnt/secondary #1 ", " /mnt/secondary/log dir ")
values, counts := parseMutatedEnvTemplate(t, got)
assertMutatedEnvValue(t, values, counts, "SECONDARY_ENABLED", "true")
assertMutatedEnvValue(t, values, counts, "SECONDARY_PATH", "'/mnt/secondary #1'")
assertMutatedEnvValue(t, values, counts, "SECONDARY_LOG_PATH", "'/mnt/secondary/log dir'")

configPath := filepath.Join(t.TempDir(), "backup.env")
if err := os.WriteFile(configPath, []byte(got), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}

raw, err := parseEnvFile(configPath)
if err != nil {
t.Fatalf("parseEnvFile() error = %v", err)
}
if gotPath := raw["SECONDARY_PATH"]; gotPath != "/mnt/secondary #1" {
t.Fatalf("SECONDARY_PATH = %q; want %q", gotPath, "/mnt/secondary #1")
}
if gotLogPath := raw["SECONDARY_LOG_PATH"]; gotLogPath != "/mnt/secondary/log dir" {
t.Fatalf("SECONDARY_LOG_PATH = %q; want %q", gotLogPath, "/mnt/secondary/log dir")
}
}
Loading
Loading