From 9911686bd84570b00d823f7349526b1c90626fab Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Wed, 25 Feb 2026 20:07:15 +0100 Subject: [PATCH 1/4] Add Telegram pairing step to installer Introduce an optional Telegram pairing/verification step in the installer (CLI and TUI) for centralized bot mode. Adds a TUI wizard (internal/tui/wizard/telegram_setup_tui.go) with retry/skip behavior, identity detection, status feedback and persistence checks, plus extensive unit tests. Wire CLI to display Server ID and perform an interactive verification loop using notify.CheckTelegramRegistration. Update docs (CLI_REFERENCE.md, CONFIGURATION.md, INSTALL.md) to document the pairing flow and install log changes, and add logging hooks to record non-blocking failures and user choices. --- cmd/proxsave/install.go | 125 +++++ cmd/proxsave/install_tui.go | 28 + docs/CLI_REFERENCE.md | 7 +- docs/CONFIGURATION.md | 13 + docs/INSTALL.md | 33 +- internal/tui/wizard/telegram_setup_tui.go | 390 +++++++++++++ .../tui/wizard/telegram_setup_tui_test.go | 529 ++++++++++++++++++ 7 files changed, 1121 insertions(+), 4 deletions(-) create mode 100644 internal/tui/wizard/telegram_setup_tui.go create mode 100644 internal/tui/wizard/telegram_setup_tui_test.go diff --git a/cmd/proxsave/install.go b/cmd/proxsave/install.go index b781773..5560623 100644 --- a/cmd/proxsave/install.go +++ b/cmd/proxsave/install.go @@ -15,6 +15,7 @@ import ( "github.com/tis24dev/proxsave/internal/config" "github.com/tis24dev/proxsave/internal/identity" "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/notify" "github.com/tis24dev/proxsave/internal/orchestrator" "github.com/tis24dev/proxsave/internal/tui/wizard" "github.com/tis24dev/proxsave/internal/types" @@ -95,6 +96,13 @@ func runInstall(ctx context.Context, configPath string, bootstrap *logging.Boots if err := runPostInstallAuditCLI(ctx, reader, execInfo.ExecPath, configPath, bootstrap); err != nil { return err } + + // Telegram setup (centralized bot): if enabled, guide the user through pairing + // and allow an explicit verification step with retry + skip. + logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "telegram setup") + if err := runTelegramSetupCLI(ctx, reader, baseDir, configPath, bootstrap); err != nil { + return err + } } logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "finalizing symlinks and cron") @@ -117,6 +125,123 @@ func runInstall(ctx context.Context, configPath string, bootstrap *logging.Boots return nil } +func runTelegramSetupCLI(ctx context.Context, reader *bufio.Reader, baseDir, configPath string, bootstrap *logging.BootstrapLogger) error { + cfg, err := config.LoadConfig(configPath) + if err != nil { + if bootstrap != nil { + bootstrap.Warning("Telegram setup: unable to load config (skipping): %v", err) + } + return nil + } + if cfg == nil || !cfg.TelegramEnabled { + return nil + } + + mode := strings.ToLower(strings.TrimSpace(cfg.TelegramBotType)) + if mode == "" { + mode = "centralized" + } + if mode == "personal" { + // No centralized pairing check exists for personal mode. + if bootstrap != nil { + bootstrap.Info("Telegram setup: personal mode selected (no centralized pairing check)") + } + return nil + } + + fmt.Println("\n--- Telegram setup (optional) ---") + fmt.Println("You enabled Telegram notifications (centralized bot).") + + info, idErr := identity.Detect(baseDir, nil) + if idErr != nil { + fmt.Printf("WARNING: Unable to compute server identity (non-blocking): %v\n", idErr) + if bootstrap != nil { + bootstrap.Warning("Telegram setup: identity detection failed (non-blocking): %v", idErr) + } + return nil + } + + serverID := "" + if info != nil { + serverID = strings.TrimSpace(info.ServerID) + } + if serverID == "" { + fmt.Println("WARNING: Server ID unavailable; skipping Telegram setup.") + if bootstrap != nil { + bootstrap.Warning("Telegram setup: server ID unavailable; skipping") + } + return nil + } + + fmt.Printf("Server ID: %s\n", serverID) + if info != nil && strings.TrimSpace(info.IdentityFile) != "" { + fmt.Printf("Identity file: %s\n", strings.TrimSpace(info.IdentityFile)) + } + fmt.Println() + fmt.Println("1) Open Telegram and start @ProxmoxAN_bot") + fmt.Println("2) Send the Server ID above (digits only)") + fmt.Println("3) Verify pairing (recommended)") + fmt.Println() + + check, err := promptYesNo(ctx, reader, "Check Telegram pairing now? [Y/n]: ", true) + if err != nil { + return wrapInstallError(err) + } + if !check { + fmt.Println("Skipped verification. You can verify later by running proxsave.") + if bootstrap != nil { + bootstrap.Info("Telegram setup: verification skipped by user") + } + return nil + } + + serverHost := strings.TrimSpace(cfg.TelegramServerAPIHost) + if serverHost == "" { + serverHost = "https://bot.tis24.it:1443" + } + + attempts := 0 + for { + attempts++ + status := notify.CheckTelegramRegistration(ctx, serverHost, serverID, nil) + if status.Code == 200 && status.Error == nil { + fmt.Println("✓ Telegram linked successfully.") + if bootstrap != nil { + bootstrap.Info("Telegram setup: verified (attempts=%d)", attempts) + } + return nil + } + + msg := strings.TrimSpace(status.Message) + if msg == "" { + msg = "Registration not active yet" + } + fmt.Printf("Telegram: %s\n", msg) + switch status.Code { + case 403, 409: + fmt.Println("Hint: Start the bot, send the Server ID, then retry.") + case 422: + fmt.Println("Hint: The Server ID appears invalid. If this persists, re-run the installer.") + default: + if status.Error != nil { + fmt.Printf("Hint: Check failed: %v\n", status.Error) + } + } + + retry, err := promptYesNo(ctx, reader, "Check again? [y/N]: ", false) + if err != nil { + return wrapInstallError(err) + } + if !retry { + fmt.Println("Verification not completed. You can retry later by running proxsave.") + if bootstrap != nil { + bootstrap.Info("Telegram setup: not verified (attempts=%d last=%d %s)", attempts, status.Code, msg) + } + return nil + } + } +} + func runPostInstallAuditCLI(ctx context.Context, reader *bufio.Reader, execPath, configPath string, bootstrap *logging.BootstrapLogger) error { fmt.Println("\n--- Post-install check (optional) ---") run, err := promptYesNo(ctx, reader, "Run a dry-run to detect unused components and reduce warnings? [Y/n]: ", true) diff --git a/cmd/proxsave/install_tui.go b/cmd/proxsave/install_tui.go index 9f4c1cd..93fb39c 100644 --- a/cmd/proxsave/install_tui.go +++ b/cmd/proxsave/install_tui.go @@ -208,6 +208,34 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo } } + // Telegram setup (centralized bot): if enabled during install, guide the user through + // pairing and allow an explicit verification step with retry + skip. + if wizardData != nil && (wizardData.NotificationMode == "telegram" || wizardData.NotificationMode == "both") { + telegramRes, telegramErr := wizard.RunTelegramSetupWizard(ctx, baseDir, configPath, buildSig) + if telegramErr != nil && bootstrap != nil { + bootstrap.Warning("Telegram setup failed (non-blocking): %v", telegramErr) + } + if bootstrap != nil && telegramRes.Shown { + if telegramRes.ConfigError != "" { + bootstrap.Warning("Telegram setup: failed to load config (non-blocking): %s", telegramRes.ConfigError) + } + if telegramRes.IdentityDetectError != "" { + bootstrap.Warning("Telegram setup: identity detection issue (non-blocking): %s", telegramRes.IdentityDetectError) + } + if telegramRes.TelegramMode == "personal" { + bootstrap.Info("Telegram setup: personal mode selected (no centralized pairing check)") + } else if telegramRes.Verified { + bootstrap.Info("Telegram setup: verified (code=%d)", telegramRes.LastStatusCode) + } else if telegramRes.SkippedVerification { + bootstrap.Info("Telegram setup: verification skipped by user") + } else if telegramRes.CheckAttempts > 0 { + bootstrap.Info("Telegram setup: not verified (attempts=%d last=%d %s)", telegramRes.CheckAttempts, telegramRes.LastStatusCode, telegramRes.LastStatusMessage) + } else { + bootstrap.Info("Telegram setup: not verified (no check performed)") + } + } + } + // Clean up legacy bash-based symlinks if bootstrap != nil { bootstrap.Info("Cleaning up legacy bash-based symlinks (if present)") diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index a5c2602..1f96c00 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -142,10 +142,11 @@ Some interactive commands support two interface modes: 5. Optionally sets up notifications (Telegram, Email; Email defaults to `EMAIL_DELIVERY_METHOD=relay`) 6. Optionally configures encryption (AGE setup) 7. (TUI) Optionally selects a cron time (HH:MM) for the `proxsave` cron entry -8. Optionally runs a post-install dry-run audit and offers to disable unused collectors (TUI: checklist; CLI: per-key prompts; actionable hints like `set BACKUP_*=false to disable`) -9. Finalizes installation (symlinks, cron migration, permission checks) +8. Optionally runs a post-install dry-run audit and offers to disable unused collectors (actionable hints like `set BACKUP_*=false to disable`) +9. (If Telegram enabled) Shows Server ID and offers pairing verification (retry/skip supported) +10. Finalizes installation (symlinks, cron migration, permission checks) -**Install log**: The installer writes a session log under `/tmp/proxsave/install-*.log` (includes post-install audit suggestions and any accepted disables). +**Install log**: The installer writes a session log under `/tmp/proxsave/install-*.log` (includes audit results and Telegram pairing outcome). ### Configuration Upgrade diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 87e655b..a3ef784 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -791,6 +791,19 @@ TELEGRAM_CHAT_ID= # Chat ID (your user ID or group ID) - **centralized**: Uses organization-wide bot (configured server-side) - **personal**: Uses your own bot (requires `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`) +**Centralized mode pairing**: +1. Enable Telegram (`TELEGRAM_ENABLED=true`, `BOT_TELEGRAM_TYPE=centralized`) +2. Get your **Server ID**: + - Shown during `--install` (TUI/CLI Telegram setup step) + - Persisted in `/identity/.server_identity` and reused on next runs + - Also printed in the normal run logs +3. Open Telegram and start `@ProxmoxAN_bot` +4. Send the Server ID to the bot +5. Verify pairing: + - **TUI installer**: press `Check` (retry supported). `Continue` appears only after success; use `Skip` (or `ESC`) to proceed without verification. + - **CLI installer**: opt into the check and retry when prompted. + - Normal runs also verify automatically and will skip Telegram if not paired yet. + **Setup personal bot**: 1. Message @BotFather on Telegram: `/newbot` 2. Copy token to `TELEGRAM_BOT_TOKEN` diff --git a/docs/INSTALL.md b/docs/INSTALL.md index eae383d..9b4eb63 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -219,6 +219,36 @@ If the configuration file already exists, the **TUI wizard** will ask whether to 6. **Encryption**: AGE encryption setup (runs sub-wizard immediately if enabled) 7. **Cron schedule**: Choose cron time (HH:MM) for the `proxsave` cron entry (TUI mode only) 8. **Post-install check (optional)**: Runs `proxsave --dry-run` and shows actionable warnings like `set BACKUP_*=false to disable`, allowing you to disable unused collectors and reduce WARNING noise +9. **Telegram pairing (optional)**: If Telegram (centralized) is enabled, shows your Server ID and lets you verify pairing with the bot (retry/skip supported) + +#### Telegram pairing wizard (TUI) + +If you enable Telegram notifications during `--install` (centralized bot), the installer opens an additional **Telegram Setup** screen after the post-install check. + +It does **not** modify your `backup.env`. It only: +- Computes/loads the **Server ID** and persists it (identity file) +- Guides you through pairing with the centralized bot +- Lets you verify pairing immediately (retry supported) + +**What you see:** +- **Instructions**: steps to start the bot and send the Server ID +- **Server ID**: digits-only identifier + identity file path/persistence status +- **Status**: live feedback from the pairing check +- **Actions**: + - `Check`: verify pairing (press again to retry) + - `Continue`: available only after a successful check (centralized mode), or immediately in personal mode / when the Server ID is unavailable + - `Skip`: leave without verification (in centralized mode, `ESC` behaves like Skip when not verified) + +**Where the Server ID is stored:** +- `/identity/.server_identity` + +**If `Check` fails:** +- `403` / `409`: start the bot, send the Server ID, then try again +- `422`: the Server ID looks invalid; re-run the installer or regenerate the identity file +- Other errors: temporary server/network issue; retry or skip and pair later + +**CLI mode:** +- With `--install --cli`, the installer prints the Server ID and asks whether to run the check now (with a retry loop). **Features:** @@ -227,7 +257,8 @@ If the configuration file already exists, the **TUI wizard** will ask whether to - Creates all necessary directories with proper permissions (0700) - Immediate AGE key generation if encryption is enabled - Optional post-install audit to disable unused collectors (keeps changes explicit; nothing is disabled silently) -- Install session log under `/tmp/proxsave/install-*.log` (includes post-install audit suggestions and any accepted disables) +- Optional Telegram pairing wizard (centralized mode) that displays Server ID and verifies the bot registration (retry/skip supported) +- Install session log under `/tmp/proxsave/install-*.log` (includes audit results and Telegram pairing outcome) After completion, edit `configs/backup.env` manually for advanced options. diff --git a/internal/tui/wizard/telegram_setup_tui.go b/internal/tui/wizard/telegram_setup_tui.go new file mode 100644 index 0000000..90e2098 --- /dev/null +++ b/internal/tui/wizard/telegram_setup_tui.go @@ -0,0 +1,390 @@ +package wizard + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/identity" + "github.com/tis24dev/proxsave/internal/notify" + "github.com/tis24dev/proxsave/internal/tui" +) + +var ( + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + return app.SetRoot(root, true).SetFocus(focus).Run() + } + + telegramSetupLoadConfig = config.LoadConfig + telegramSetupReadFile = os.ReadFile + telegramSetupStat = os.Stat + telegramSetupIdentityDetect = identity.Detect + telegramSetupCheckRegistration = notify.CheckTelegramRegistration + telegramSetupQueueUpdateDraw = func(app *tui.App, f func()) { app.QueueUpdateDraw(f) } + telegramSetupGo = func(fn func()) { go fn() } +) + +type TelegramSetupResult struct { + Shown bool + + ConfigLoaded bool + ConfigError string + + TelegramEnabled bool + TelegramMode string + ServerAPIHost string + + ServerID string + IdentityFile string + IdentityPersisted bool + IdentityDetectError string + + CheckAttempts int + Verified bool + LastStatusCode int + LastStatusMessage string + LastStatusError string + + SkippedVerification bool +} + +func RunTelegramSetupWizard(ctx context.Context, baseDir, configPath, buildSig string) (TelegramSetupResult, error) { + result := TelegramSetupResult{Shown: true} + + cfg, cfgErr := telegramSetupLoadConfig(configPath) + if cfgErr != nil { + result.ConfigLoaded = false + result.ConfigError = cfgErr.Error() + // Fall back to raw env parsing so the wizard can still run even when the full + // config parser fails for unrelated keys. + if configBytes, readErr := telegramSetupReadFile(configPath); readErr == nil { + values := parseEnvTemplate(string(configBytes)) + result.TelegramEnabled = readTemplateBool(values, "TELEGRAM_ENABLED") + result.TelegramMode = strings.ToLower(strings.TrimSpace(readTemplateString(values, "BOT_TELEGRAM_TYPE"))) + } + } else { + result.ConfigLoaded = true + result.TelegramEnabled = cfg.TelegramEnabled + result.TelegramMode = strings.ToLower(strings.TrimSpace(cfg.TelegramBotType)) + result.ServerAPIHost = strings.TrimSpace(cfg.TelegramServerAPIHost) + } + + if !result.TelegramEnabled { + result.Shown = false + return result, nil + } + if result.TelegramMode == "" { + result.TelegramMode = "centralized" + } + if result.ServerAPIHost == "" { + // Fallback (keeps behavior aligned with internal/config defaults). + result.ServerAPIHost = "https://bot.tis24.it:1443" + } + + idInfo, idErr := telegramSetupIdentityDetect(baseDir, nil) + if idErr != nil { + result.IdentityDetectError = idErr.Error() + } + if idInfo != nil { + result.ServerID = strings.TrimSpace(idInfo.ServerID) + result.IdentityFile = strings.TrimSpace(idInfo.IdentityFile) + if result.IdentityFile != "" { + if _, err := telegramSetupStat(result.IdentityFile); err == nil { + result.IdentityPersisted = true + } + } + } + + app := tui.NewApp() + pages := tview.NewPages() + + titleText := tview.NewTextView(). + SetText("ProxSave - Telegram Setup\n\n" + + "Telegram notifications are enabled.\n" + + "Complete the bot pairing now to avoid warning noise and skipped notifications.\n"). + SetTextColor(tui.ProxmoxLight). + SetDynamicColors(true) + titleText.SetBorder(false) + + nav := tview.NewTextView(). + SetText("[yellow]Navigation:[white] TAB/↑↓ to move | ENTER to select | ESC to exit"). + SetTextColor(tcell.ColorWhite). + SetDynamicColors(true). + SetTextAlign(tview.AlignCenter) + nav.SetBorder(false) + + separator := tview.NewTextView(). + SetText(strings.Repeat("─", 80)). + SetTextColor(tui.ProxmoxOrange) + separator.SetBorder(false) + + configPathText := tview.NewTextView(). + SetText(fmt.Sprintf("[yellow]Configuration file:[white] %s", configPath)). + SetTextColor(tcell.ColorWhite). + SetDynamicColors(true). + SetTextAlign(tview.AlignCenter) + configPathText.SetBorder(false) + + buildSigText := tview.NewTextView(). + SetText(fmt.Sprintf("[yellow]Build Signature:[white] %s", buildSig)). + SetTextColor(tcell.ColorWhite). + SetDynamicColors(true). + SetTextAlign(tview.AlignCenter) + buildSigText.SetBorder(false) + + instructions := tview.NewTextView(). + SetDynamicColors(true). + SetWrap(true) + instructions.SetBorder(true). + SetTitle(" Instructions "). + SetTitleAlign(tview.AlignCenter). + SetTitleColor(tui.ProxmoxOrange). + SetBorderColor(tui.ProxmoxOrange) + + serverIDView := tview.NewTextView(). + SetDynamicColors(true). + SetTextAlign(tview.AlignCenter) + serverIDView.SetBorder(true). + SetTitle(" Server ID "). + SetTitleAlign(tview.AlignCenter). + SetTitleColor(tui.ProxmoxOrange). + SetBorderColor(tui.ProxmoxOrange) + + statusView := tview.NewTextView(). + SetDynamicColors(true). + SetWrap(true) + statusView.SetBorder(true). + SetTitle(" Status "). + SetTitleAlign(tview.AlignCenter). + SetTitleColor(tui.ProxmoxOrange). + SetBorderColor(tui.ProxmoxOrange) + + truncate := func(s string, max int) string { + s = strings.TrimSpace(s) + if max <= 0 || len(s) <= max { + return s + } + return s[:max] + "...(truncated)" + } + + modeLabel := result.TelegramMode + if modeLabel == "" { + modeLabel = "centralized" + } + + var b strings.Builder + b.WriteString(fmt.Sprintf("[yellow]Mode:[white] %s\n", modeLabel)) + if !result.ConfigLoaded && result.ConfigError != "" { + b.WriteString(fmt.Sprintf("[red]WARNING:[white] failed to load config: %s\n\n", truncate(result.ConfigError, 200))) + } + if result.TelegramMode == "personal" { + b.WriteString("\nPersonal mode uses your own bot.\n\n") + b.WriteString("This installer does not guide the personal bot setup.\n") + b.WriteString("Edit backup.env and set:\n") + b.WriteString(" - TELEGRAM_BOT_TOKEN\n") + b.WriteString(" - TELEGRAM_CHAT_ID\n\n") + b.WriteString("Then run ProxSave once to validate notifications.\n") + } else { + b.WriteString("\n1) Open Telegram and start [yellow]@ProxmoxAN_bot[white]\n") + b.WriteString("2) Send the [yellow]Server ID[white] below (digits only)\n") + b.WriteString("3) Press [yellow]Check[white] to verify\n\n") + b.WriteString("If the check fails, you can press Check again.\n") + b.WriteString("You can also Skip verification and complete pairing later.\n") + } + instructions.SetText(b.String()) + + serverIDLine := "[red]Server ID unavailable.[white]" + if result.ServerID != "" { + serverIDLine = fmt.Sprintf("[yellow]%s[white]", result.ServerID) + } + identityLine := "" + if result.IdentityFile != "" { + persisted := "not persisted" + if result.IdentityPersisted { + persisted = "persisted" + } + identityLine = fmt.Sprintf("\n[gray]Identity file:[white] %s ([yellow]%s[white])", result.IdentityFile, persisted) + } + serverIDView.SetText(serverIDLine + identityLine) + + setStatus := func(text string) { + statusView.SetText(text) + } + + initialStatus := "[yellow]Not checked yet.[white]\n\nPress [yellow]Check[white] after sending the Server ID to the bot." + if result.TelegramMode == "personal" { + initialStatus = "[yellow]No centralized pairing check for personal mode.[white]" + } + if result.ServerID == "" && result.TelegramMode != "personal" { + initialStatus = "[red]Cannot check registration: Server ID missing.[white]" + if result.IdentityDetectError != "" { + initialStatus += "\n\n" + truncate(result.IdentityDetectError, 200) + } + } + setStatus(initialStatus) + + var mu sync.Mutex + checking := false + closing := false + + checkCtx, cancelChecks := context.WithCancel(ctx) + defer cancelChecks() + + form := tview.NewForm() + form.SetBorder(true). + SetTitle(" Actions "). + SetTitleAlign(tview.AlignCenter). + SetTitleColor(tui.ProxmoxOrange). + SetBorderColor(tui.ProxmoxOrange). + SetBackgroundColor(tcell.ColorBlack) + + doClose := func(skipped bool) { + mu.Lock() + closing = true + result.SkippedVerification = skipped + mu.Unlock() + cancelChecks() + app.Stop() + } + + var refreshButtons func() + + checkHandler := func() { + if result.TelegramMode == "personal" || strings.TrimSpace(result.ServerID) == "" { + return + } + + mu.Lock() + if checking || closing { + mu.Unlock() + return + } + checking = true + mu.Unlock() + + setStatus("[blue]Checking registration…[white]\n\nPlease wait.") + serverID := result.ServerID + telegramSetupGo(func() { + status := telegramSetupCheckRegistration(checkCtx, result.ServerAPIHost, serverID, nil) + telegramSetupQueueUpdateDraw(app, func() { + mu.Lock() + defer mu.Unlock() + if closing { + return + } + checking = false + + result.CheckAttempts++ + result.LastStatusCode = status.Code + result.LastStatusMessage = status.Message + if status.Error != nil { + result.LastStatusError = status.Error.Error() + } else { + result.LastStatusError = "" + } + + if status.Code == 200 && status.Error == nil { + result.Verified = true + setStatus(fmt.Sprintf("[green]✓ Linked successfully.[white]\n\n%s", status.Message)) + if refreshButtons != nil { + refreshButtons() + } + return + } + + msg := status.Message + if msg == "" { + msg = "Registration not active yet" + } + var hint string + switch status.Code { + case 403, 409: + hint = "\n\nStart the bot, send the Server ID, then press Check again." + case 422: + hint = "\n\nThe Server ID appears invalid. If this persists, re-run the installer or regenerate the identity file." + default: + hint = "\n\nYou can press Check again, or Skip verification and complete pairing later." + } + setStatus(fmt.Sprintf("[yellow]%s[white]%s", truncate(msg, 300), hint)) + }) + }) + } + + refreshButtons = func() { + form.ClearButtons() + + // Centralized mode pairing only works when the Server ID is available. + if result.TelegramMode != "personal" && strings.TrimSpace(result.ServerID) != "" { + form.AddButton("Check", checkHandler) + } + + switch { + case result.TelegramMode == "personal": + form.AddButton("Continue", func() { doClose(false) }) + case strings.TrimSpace(result.ServerID) == "": + form.AddButton("Continue", func() { doClose(false) }) + case result.Verified: + form.AddButton("Continue", func() { doClose(false) }) + default: + // Until verification succeeds, require an explicit skip to leave without pairing. + form.AddButton("Skip", func() { doClose(true) }) + } + } + refreshButtons() + + body := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(instructions, 0, 3, false). + AddItem(serverIDView, 4, 0, false). + AddItem(statusView, 0, 2, false). + AddItem(form, 7, 0, true) + + body.SetBorder(true). + SetTitle(" Telegram Setup "). + SetTitleAlign(tview.AlignCenter). + SetTitleColor(tui.ProxmoxOrange). + SetBorderColor(tui.ProxmoxOrange). + SetBackgroundColor(tcell.ColorBlack) + + pages.AddPage("main", body, true, true) + + layout := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(titleText, 5, 0, false). + AddItem(nav, 2, 0, false). + AddItem(separator, 1, 0, false). + AddItem(pages, 0, 1, true). + AddItem(configPathText, 1, 0, false). + AddItem(buildSigText, 1, 0, false) + + layout.SetBorder(true). + SetTitle(" ProxSave "). + SetTitleAlign(tview.AlignCenter). + SetTitleColor(tui.ProxmoxOrange). + SetBorderColor(tui.ProxmoxOrange). + SetBackgroundColor(tcell.ColorBlack) + + app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + if result.TelegramMode != "personal" && strings.TrimSpace(result.ServerID) != "" && !result.Verified { + doClose(true) + } else { + doClose(false) + } + return nil + } + return event + }) + + if runErr := telegramSetupWizardRunner(app, layout, form); runErr != nil { + return TelegramSetupResult{}, runErr + } + + return result, nil +} diff --git a/internal/tui/wizard/telegram_setup_tui_test.go b/internal/tui/wizard/telegram_setup_tui_test.go new file mode 100644 index 0000000..19e7a8f --- /dev/null +++ b/internal/tui/wizard/telegram_setup_tui_test.go @@ -0,0 +1,529 @@ +package wizard + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/tis24dev/proxsave/internal/config" + "github.com/tis24dev/proxsave/internal/identity" + "github.com/tis24dev/proxsave/internal/logging" + "github.com/tis24dev/proxsave/internal/notify" + "github.com/tis24dev/proxsave/internal/tui" +) + +func stubTelegramSetupDeps(t *testing.T) { + t.Helper() + + origRunner := telegramSetupWizardRunner + origLoadConfig := telegramSetupLoadConfig + origReadFile := telegramSetupReadFile + origStat := telegramSetupStat + origIdentityDetect := telegramSetupIdentityDetect + origCheckRegistration := telegramSetupCheckRegistration + origQueueUpdateDraw := telegramSetupQueueUpdateDraw + origGo := telegramSetupGo + + t.Cleanup(func() { + telegramSetupWizardRunner = origRunner + telegramSetupLoadConfig = origLoadConfig + telegramSetupReadFile = origReadFile + telegramSetupStat = origStat + telegramSetupIdentityDetect = origIdentityDetect + telegramSetupCheckRegistration = origCheckRegistration + telegramSetupQueueUpdateDraw = origQueueUpdateDraw + telegramSetupGo = origGo + }) + + telegramSetupGo = func(fn func()) { fn() } + telegramSetupQueueUpdateDraw = func(app *tui.App, f func()) { f() } +} + +func TestRunTelegramSetupWizard_DisabledSkipsUIAndRunnerNotCalled(t *testing.T) { + stubTelegramSetupDeps(t) + + telegramSetupLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{TelegramEnabled: false}, nil + } + telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + t.Fatalf("identity detect should not be called when telegram is disabled") + return nil, nil + } + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + t.Fatalf("runner should not be called when telegram is disabled") + return nil + } + + result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/nope/backup.env", "sig") + if err != nil { + t.Fatalf("RunTelegramSetupWizard error: %v", err) + } + if result.Shown { + t.Fatalf("expected wizard to not be shown") + } + if !result.ConfigLoaded { + t.Fatalf("expected ConfigLoaded=true") + } + if result.TelegramEnabled { + t.Fatalf("expected TelegramEnabled=false") + } +} + +func TestRunTelegramSetupWizard_ConfigLoadAndReadFailSkipsUI(t *testing.T) { + stubTelegramSetupDeps(t) + + telegramSetupLoadConfig = func(path string) (*config.Config, error) { + return nil, errors.New("parse failed") + } + telegramSetupReadFile = func(path string) ([]byte, error) { + return nil, errors.New("read failed") + } + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + t.Fatalf("runner should not be called when env cannot be read") + return nil + } + + result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/nope/backup.env", "sig") + if err != nil { + t.Fatalf("RunTelegramSetupWizard error: %v", err) + } + if result.Shown { + t.Fatalf("expected wizard to not be shown") + } + if result.ConfigLoaded { + t.Fatalf("expected ConfigLoaded=false") + } + if result.ConfigError == "" { + t.Fatalf("expected ConfigError to be set") + } +} + +func TestRunTelegramSetupWizard_FallbackPersonalMode_Continue(t *testing.T) { + stubTelegramSetupDeps(t) + + identityFile := filepath.Join(t.TempDir(), ".server_identity") + if err := os.WriteFile(identityFile, []byte("id"), 0o600); err != nil { + t.Fatalf("write identity file: %v", err) + } + + telegramSetupLoadConfig = func(path string) (*config.Config, error) { + return nil, errors.New(strings.Repeat("x", 250)) + } + telegramSetupReadFile = func(path string) ([]byte, error) { + return []byte("TELEGRAM_ENABLED=true\nBOT_TELEGRAM_TYPE=Personal\n"), nil + } + telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + return &identity.Info{ServerID: " 123 ", IdentityFile: " " + identityFile + " "}, nil + } + telegramSetupStat = os.Stat + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + form := focus.(*tview.Form) + if form.GetButtonIndex("Check") != -1 { + t.Fatalf("expected no Check button in personal mode") + } + if form.GetButtonIndex("Continue") == -1 { + t.Fatalf("expected Continue button in personal mode") + } + pressFormButton(t, form, "Continue") + return nil + } + + result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") + if err != nil { + t.Fatalf("RunTelegramSetupWizard error: %v", err) + } + if !result.Shown { + t.Fatalf("expected wizard to be shown") + } + if result.ConfigLoaded { + t.Fatalf("expected ConfigLoaded=false for fallback mode") + } + if result.ConfigError == "" { + t.Fatalf("expected ConfigError to be set") + } + if !result.TelegramEnabled { + t.Fatalf("expected TelegramEnabled=true") + } + if result.TelegramMode != "personal" { + t.Fatalf("TelegramMode=%q, want personal", result.TelegramMode) + } + if result.ServerAPIHost != "https://bot.tis24.it:1443" { + t.Fatalf("ServerAPIHost=%q, want default", result.ServerAPIHost) + } + if result.ServerID != "123" { + t.Fatalf("ServerID=%q, want 123", result.ServerID) + } + if !result.IdentityPersisted { + t.Fatalf("expected IdentityPersisted=true") + } + if result.Verified { + t.Fatalf("expected Verified=false") + } + if result.SkippedVerification { + t.Fatalf("expected SkippedVerification=false") + } + if result.CheckAttempts != 0 { + t.Fatalf("CheckAttempts=%d, want 0", result.CheckAttempts) + } +} + +func TestRunTelegramSetupWizard_CentralizedSuccess_RequiresCheckBeforeContinue(t *testing.T) { + stubTelegramSetupDeps(t) + + telegramSetupLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{ + TelegramEnabled: true, + TelegramBotType: " ", + TelegramServerAPIHost: " https://api.example.test ", + }, nil + } + telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + return &identity.Info{ServerID: " 987654321 ", IdentityFile: " /missing "}, nil + } + telegramSetupStat = func(path string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { + if serverAPIHost != "https://api.example.test" { + t.Fatalf("serverAPIHost=%q, want https://api.example.test", serverAPIHost) + } + if serverID != "987654321" { + t.Fatalf("serverID=%q, want 987654321", serverID) + } + return notify.TelegramRegistrationStatus{Code: 200, Message: "ok"} + } + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + form := focus.(*tview.Form) + if form.GetButtonIndex("Continue") != -1 { + t.Fatalf("expected no Continue button before verification") + } + if form.GetButtonIndex("Skip") == -1 { + t.Fatalf("expected Skip button before verification") + } + if form.GetButtonIndex("Check") == -1 { + t.Fatalf("expected Check button before verification") + } + + pressFormButton(t, form, "Check") + + if form.GetButtonIndex("Skip") != -1 { + t.Fatalf("expected Skip button to be removed after verification") + } + if form.GetButtonIndex("Continue") == -1 { + t.Fatalf("expected Continue button after verification") + } + pressFormButton(t, form, "Continue") + return nil + } + + result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") + if err != nil { + t.Fatalf("RunTelegramSetupWizard error: %v", err) + } + if !result.Shown { + t.Fatalf("expected wizard to be shown") + } + if !result.ConfigLoaded { + t.Fatalf("expected ConfigLoaded=true") + } + if result.TelegramMode != "centralized" { + t.Fatalf("TelegramMode=%q, want centralized", result.TelegramMode) + } + if result.ServerAPIHost != "https://api.example.test" { + t.Fatalf("ServerAPIHost=%q, want https://api.example.test", result.ServerAPIHost) + } + if result.IdentityPersisted { + t.Fatalf("expected IdentityPersisted=false") + } + if !result.Verified { + t.Fatalf("expected Verified=true") + } + if result.CheckAttempts != 1 { + t.Fatalf("CheckAttempts=%d, want 1", result.CheckAttempts) + } + if result.LastStatusCode != 200 { + t.Fatalf("LastStatusCode=%d, want 200", result.LastStatusCode) + } + if result.SkippedVerification { + t.Fatalf("expected SkippedVerification=false") + } +} + +func TestRunTelegramSetupWizard_CentralizedFailure_CanRetryAndSkip(t *testing.T) { + stubTelegramSetupDeps(t) + + var calls int + telegramSetupLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{ + TelegramEnabled: true, + TelegramBotType: "centralized", + TelegramServerAPIHost: "https://api.example.test", + }, nil + } + telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + return &identity.Info{ServerID: "111222333"}, nil + } + telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { + calls++ + if calls == 1 { + return notify.TelegramRegistrationStatus{Code: 403, Error: errors.New("not registered")} + } + if calls == 2 { + return notify.TelegramRegistrationStatus{Code: 422, Message: "invalid"} + } + return notify.TelegramRegistrationStatus{Code: 500, Message: "oops"} + } + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + form := focus.(*tview.Form) + pressFormButton(t, form, "Check") + pressFormButton(t, form, "Check") + pressFormButton(t, form, "Check") + if form.GetButtonIndex("Continue") != -1 { + t.Fatalf("expected no Continue button when not verified") + } + pressFormButton(t, form, "Skip") + return nil + } + + result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") + if err != nil { + t.Fatalf("RunTelegramSetupWizard error: %v", err) + } + if !result.Shown { + t.Fatalf("expected wizard to be shown") + } + if result.Verified { + t.Fatalf("expected Verified=false") + } + if !result.SkippedVerification { + t.Fatalf("expected SkippedVerification=true") + } + if result.CheckAttempts != 3 { + t.Fatalf("CheckAttempts=%d, want 3", result.CheckAttempts) + } + if result.LastStatusCode != 500 { + t.Fatalf("LastStatusCode=%d, want 500", result.LastStatusCode) + } + if calls != 3 { + t.Fatalf("check calls=%d, want 3", calls) + } +} + +func TestRunTelegramSetupWizard_CentralizedMissingServerID_ExitsOnEscWithoutSkipping(t *testing.T) { + stubTelegramSetupDeps(t) + + telegramSetupLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{ + TelegramEnabled: true, + TelegramBotType: "centralized", + TelegramServerAPIHost: "https://api.example.test", + }, nil + } + telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + return nil, errors.New("detect failed") + } + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + form := focus.(*tview.Form) + if form.GetButtonIndex("Check") != -1 { + t.Fatalf("expected no Check button without Server ID") + } + if form.GetButtonIndex("Skip") != -1 { + t.Fatalf("expected no Skip button without Server ID") + } + if form.GetButtonIndex("Continue") == -1 { + t.Fatalf("expected Continue button without Server ID") + } + + capture := app.GetInputCapture() + if capture == nil { + t.Fatalf("expected input capture to be set") + } + capture(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone)) + return nil + } + + result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") + if err != nil { + t.Fatalf("RunTelegramSetupWizard error: %v", err) + } + if result.SkippedVerification { + t.Fatalf("expected SkippedVerification=false") + } + if result.Verified { + t.Fatalf("expected Verified=false") + } + if result.CheckAttempts != 0 { + t.Fatalf("CheckAttempts=%d, want 0", result.CheckAttempts) + } + if result.ServerID != "" { + t.Fatalf("ServerID=%q, want empty", result.ServerID) + } + if result.IdentityDetectError == "" { + t.Fatalf("expected IdentityDetectError to be set") + } +} + +func TestRunTelegramSetupWizard_CentralizedMissingServerID_CanContinueButton(t *testing.T) { + stubTelegramSetupDeps(t) + + telegramSetupLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{ + TelegramEnabled: true, + TelegramBotType: "centralized", + TelegramServerAPIHost: "https://api.example.test", + }, nil + } + telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + return &identity.Info{ServerID: ""}, nil + } + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + form := focus.(*tview.Form) + pressFormButton(t, form, "Continue") + return nil + } + + result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") + if err != nil { + t.Fatalf("RunTelegramSetupWizard error: %v", err) + } + if result.SkippedVerification { + t.Fatalf("expected SkippedVerification=false") + } + if result.Verified { + t.Fatalf("expected Verified=false") + } + if result.CheckAttempts != 0 { + t.Fatalf("CheckAttempts=%d, want 0", result.CheckAttempts) + } +} + +func TestRunTelegramSetupWizard_CentralizedEscSkipsWhenNotVerified(t *testing.T) { + stubTelegramSetupDeps(t) + + telegramSetupLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{ + TelegramEnabled: true, + TelegramBotType: "centralized", + TelegramServerAPIHost: "https://api.example.test", + }, nil + } + telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + return &identity.Info{ServerID: "123456"}, nil + } + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + capture := app.GetInputCapture() + if capture == nil { + t.Fatalf("expected input capture to be set") + } + + nonEsc := tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone) + if got := capture(nonEsc); got != nonEsc { + t.Fatalf("expected non-ESC to pass through") + } + + capture(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone)) + return nil + } + + result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") + if err != nil { + t.Fatalf("RunTelegramSetupWizard error: %v", err) + } + if !result.SkippedVerification { + t.Fatalf("expected SkippedVerification=true when exiting with ESC before verification") + } + if result.Verified { + t.Fatalf("expected Verified=false") + } + if result.CheckAttempts != 0 { + t.Fatalf("CheckAttempts=%d, want 0", result.CheckAttempts) + } +} + +func TestRunTelegramSetupWizard_PropagatesRunnerError(t *testing.T) { + stubTelegramSetupDeps(t) + + telegramSetupLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{ + TelegramEnabled: true, + TelegramBotType: "centralized", + TelegramServerAPIHost: "https://api.example.test", + }, nil + } + telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + return &identity.Info{ServerID: "123456"}, nil + } + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + return errors.New("runner failed") + } + + result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") + if err == nil { + t.Fatalf("expected error") + } + if result != (TelegramSetupResult{}) { + t.Fatalf("expected empty result on runner error, got %#v", result) + } +} + +func TestRunTelegramSetupWizard_CheckIgnoredWhileChecking_AndUpdateSuppressedAfterClose(t *testing.T) { + stubTelegramSetupDeps(t) + + var pending func() + var checkCalls int + + telegramSetupGo = func(fn func()) { pending = fn } + telegramSetupQueueUpdateDraw = func(app *tui.App, f func()) { f() } + + telegramSetupLoadConfig = func(path string) (*config.Config, error) { + return &config.Config{ + TelegramEnabled: true, + TelegramBotType: "centralized", + TelegramServerAPIHost: "https://api.example.test", + }, nil + } + telegramSetupIdentityDetect = func(baseDir string, logger *logging.Logger) (*identity.Info, error) { + return &identity.Info{ServerID: "999888777"}, nil + } + telegramSetupCheckRegistration = func(ctx context.Context, serverAPIHost, serverID string, logger *logging.Logger) notify.TelegramRegistrationStatus { + checkCalls++ + return notify.TelegramRegistrationStatus{Code: 200, Message: "ok"} + } + telegramSetupWizardRunner = func(app *tui.App, root, focus tview.Primitive) error { + form := focus.(*tview.Form) + + pressFormButton(t, form, "Check") + if pending == nil { + t.Fatalf("expected pending check goroutine") + } + + pressFormButton(t, form, "Check") // should be ignored while checking=true + pressFormButton(t, form, "Skip") // closes the wizard + + pending() // simulate late completion after closing + return nil + } + + result, err := RunTelegramSetupWizard(context.Background(), t.TempDir(), "/fake/backup.env", "sig") + if err != nil { + t.Fatalf("RunTelegramSetupWizard error: %v", err) + } + if !result.SkippedVerification { + t.Fatalf("expected SkippedVerification=true") + } + if result.Verified { + t.Fatalf("expected Verified=false when update is suppressed after close") + } + if result.CheckAttempts != 0 { + t.Fatalf("CheckAttempts=%d, want 0 when update is suppressed after close", result.CheckAttempts) + } + if checkCalls != 1 { + t.Fatalf("checkCalls=%d, want 1", checkCalls) + } +} From 63017dc6370c9f096b003daecaee5182c7b45f99 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:23:08 +0100 Subject: [PATCH 2/4] Support non-interactive upgrade auto-confirm Add support for auto-confirming the --upgrade flow by accepting a trailing 'y' (e.g. --upgrade y). Introduces Args.UpgradeAutoYes, extractUpgradeAutoYesArgs to preprocess os.Args, and updates Parse() to use the processed args. The upgrade command now respects UpgradeAutoYes and skips the interactive prompt (with a debug log). Documentation updated with examples and a unit test added to validate parsing behavior. --- cmd/proxsave/upgrade.go | 18 +++++++++++------ docs/CLI_REFERENCE.md | 3 +++ docs/INSTALL.md | 3 +++ internal/cli/args.go | 41 +++++++++++++++++++++++++++++++++++++-- internal/cli/args_test.go | 17 ++++++++++++++++ 5 files changed, 74 insertions(+), 8 deletions(-) diff --git a/cmd/proxsave/upgrade.go b/cmd/proxsave/upgrade.go index befe961..9f4ff1f 100644 --- a/cmd/proxsave/upgrade.go +++ b/cmd/proxsave/upgrade.go @@ -126,12 +126,18 @@ func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.Bootstra bootstrap.Printf("Latest available version: %s (current: %s)", latestVersion, currentVersion) - reader := bufio.NewReader(os.Stdin) - confirm, err := promptYesNo(ctx, reader, "Do you want to download and install this version now? (backup.env will be updated with any missing keys; a backup will be created) [y/N]: ", false) - if err != nil { - bootstrap.Error("ERROR: %v", err) - workflowErr = err - return types.ExitConfigError.Int() + confirm := args.UpgradeAutoYes + if confirm { + logging.DebugStepBootstrap(bootstrap, "upgrade workflow", "auto-confirm enabled (--upgrade y)") + } else { + reader := bufio.NewReader(os.Stdin) + var err error + confirm, err = promptYesNo(ctx, reader, "Do you want to download and install this version now? (backup.env will be updated with any missing keys; a backup will be created) [y/N]: ", false) + if err != nil { + bootstrap.Error("ERROR: %v", err) + workflowErr = err + return types.ExitConfigError.Int() + } } if !confirm { bootstrap.Println("Upgrade cancelled by user; no changes were made.") diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 1f96c00..c985046 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -174,6 +174,9 @@ Some interactive commands support two interface modes: # Upgrade binary to latest version ./build/proxsave --upgrade +# Non-interactive upgrade (auto-confirm) +./build/proxsave --upgrade y + # Full upgrade including configuration ./build/proxsave --upgrade ./build/proxsave --upgrade-config diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 9b4eb63..43b7954 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -85,6 +85,9 @@ ProxSave provides a built-in upgrade command to update your installation to the # Upgrade to latest version ./build/proxsave --upgrade +# Non-interactive upgrade (auto-confirm) +./build/proxsave --upgrade y + # Optionally update configuration template ./build/proxsave --upgrade-config diff --git a/internal/cli/args.go b/internal/cli/args.go index 8c182ed..6919c96 100644 --- a/internal/cli/args.go +++ b/internal/cli/args.go @@ -30,6 +30,7 @@ type Args struct { ShowVersion bool ShowHelp bool Upgrade bool + UpgradeAutoYes bool ForceNewKey bool Decrypt bool Restore bool @@ -96,7 +97,7 @@ func Parse() *Args { flag.BoolVar(&args.NewInstall, "new-install", false, "Reset the installation directory (preserving env/identity) and launch the interactive installer") flag.BoolVar(&args.Upgrade, "upgrade", false, - "Download and install the latest ProxSave binary (also upgrades backup.env by adding missing keys from the new template)") + "Download and install the latest ProxSave binary (also upgrades backup.env by adding missing keys from the new template). Append 'y' to auto-confirm (e.g., --upgrade y)") flag.BoolVar(&args.EnvMigration, "env-migration", false, "Run the installer and migrate a legacy Bash backup.env to the Go template") flag.BoolVar(&args.EnvMigrationDry, "env-migration-dry-run", false, @@ -121,7 +122,13 @@ func Parse() *Args { } // Parse flags - flag.Parse() + processedArgs, upgradeAutoYes := extractUpgradeAutoYesArgs(os.Args) + args.UpgradeAutoYes = upgradeAutoYes + if len(processedArgs) > 1 { + _ = flag.CommandLine.Parse(processedArgs[1:]) + } else { + _ = flag.CommandLine.Parse(nil) + } args.ConfigPath = configFlag.value if configFlag.set { @@ -140,6 +147,36 @@ func Parse() *Args { return args } +func extractUpgradeAutoYesArgs(argv []string) ([]string, bool) { + if len(argv) == 0 { + return argv, false + } + + processed := make([]string, 0, len(argv)) + processed = append(processed, argv[0]) + autoYes := false + + for i := 1; i < len(argv); i++ { + arg := argv[i] + if arg == "--" { + processed = append(processed, argv[i:]...) + break + } + + processed = append(processed, arg) + if arg != "--upgrade" && arg != "-upgrade" { + continue + } + + if i+1 < len(argv) && strings.EqualFold(argv[i+1], "y") { + autoYes = true + i++ + } + } + + return processed, autoYes +} + // parseLogLevel converts string to LogLevel func parseLogLevel(s string) types.LogLevel { switch s { diff --git a/internal/cli/args_test.go b/internal/cli/args_test.go index 882f6b5..e48fbde 100644 --- a/internal/cli/args_test.go +++ b/internal/cli/args_test.go @@ -152,6 +152,23 @@ func TestParseAliasFlags(t *testing.T) { } } +func TestParseUpgradeAutoYes(t *testing.T) { + args := parseWithArgs(t, []string{ + "--upgrade", "y", + "--log-level", "debug", + }) + + if !args.Upgrade { + t.Fatal("Upgrade should be true when --upgrade is provided") + } + if !args.UpgradeAutoYes { + t.Fatal("UpgradeAutoYes should be true when --upgrade y is provided") + } + if args.LogLevel != types.LogLevelDebug { + t.Fatalf("LogLevel = %v, want debug", args.LogLevel) + } +} + func parseWithArgs(t *testing.T, cliArgs []string) *Args { t.Helper() origCommandLine := flag.CommandLine From 03334bef57d0a4f52472ae279528362f61f7bba9 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Fri, 27 Feb 2026 00:13:19 +0100 Subject: [PATCH 3/4] Anticipate privilege context detection and reuse it in collectors Move the unprivileged/rootless heuristic earlier in startup (right after logging Environment: ...), emit one INFO summary (Privilege context: ...) plus detailed DEBUG diagnostics, and inject the cached result into the orchestrator so collectors reuse it for privilege-sensitive command handling without re-reading /proc. Update environment + orchestrator tests for the new structured details and injection. --- cmd/proxsave/main.go | 3 + cmd/proxsave/userns_context.go | 43 +++++++ internal/environment/unprivileged.go | 138 +++++++++++++++++---- internal/environment/unprivileged_test.go | 79 ++++++++++++ internal/orchestrator/orchestrator.go | 30 ++++- internal/orchestrator/orchestrator_test.go | 24 ++++ 6 files changed, 293 insertions(+), 24 deletions(-) create mode 100644 cmd/proxsave/userns_context.go diff --git a/cmd/proxsave/main.go b/cmd/proxsave/main.go index 185e7b6..73d06bd 100644 --- a/cmd/proxsave/main.go +++ b/cmd/proxsave/main.go @@ -770,6 +770,8 @@ func run() int { // Log environment info logging.Info("Environment: %s %s", envInfo.Type, envInfo.Version) + unprivilegedInfo := environment.DetectUnprivilegedContainer() + logUserNamespaceContext(logger, unprivilegedInfo) logging.Info("Backup enabled: %v", cfg.BackupEnabled) logging.Info("Debug level: %s", logLevel.String()) logging.Info("Compression: %s (level %d, mode %s)", cfg.CompressionType, cfg.CompressionLevel, cfg.CompressionMode) @@ -935,6 +937,7 @@ func run() int { logging.Step("Initializing backup orchestrator") orchInitDone := logging.DebugStart(logger, "orchestrator init", "dry_run=%v", dryRun) orch = orchestrator.New(logger, dryRun) + orch.SetUnprivilegedContainerContext(unprivilegedInfo.Detected, unprivilegedInfo.Details) orch.SetVersion(toolVersion) orch.SetConfig(cfg) orch.SetIdentity(serverIDValue, serverMACValue) diff --git a/cmd/proxsave/userns_context.go b/cmd/proxsave/userns_context.go new file mode 100644 index 0000000..5bc33f9 --- /dev/null +++ b/cmd/proxsave/userns_context.go @@ -0,0 +1,43 @@ +package main + +import ( + "strings" + + "github.com/tis24dev/proxsave/internal/environment" + "github.com/tis24dev/proxsave/internal/logging" +) + +func logUserNamespaceContext(logger *logging.Logger, info environment.UnprivilegedContainerInfo) { + mode := "unknown" + note := "" + switch { + case info.Detected: + mode = "unprivileged/rootless" + note = " (some inventory may be skipped)" + case info.UIDMap.OK || info.GIDMap.OK: + mode = "privileged" + default: + note = " (cannot infer; some inventory may be skipped)" + } + + container := "" + if info.SystemdContainer.OK { + container = strings.TrimSpace(info.SystemdContainer.Value) + } + containerHint := "" + if container != "" { + containerHint = " (container=" + container + ")" + } + + logging.Info("Privilege context: %s%s%s", mode, containerHint, note) + + done := logging.DebugStart(logger, "privilege context detection", "") + logging.DebugStep(logger, "privilege context detection", "uid_map: path=%s ok=%v outside0=%d length=%d read_err=%q parse_err=%q evidence=%q", + info.UIDMap.SourcePath, info.UIDMap.OK, info.UIDMap.Outside0, info.UIDMap.Length, info.UIDMap.ReadError, info.UIDMap.ParseError, info.UIDMap.RawEvidence) + logging.DebugStep(logger, "privilege context detection", "gid_map: path=%s ok=%v outside0=%d length=%d read_err=%q parse_err=%q evidence=%q", + info.GIDMap.SourcePath, info.GIDMap.OK, info.GIDMap.Outside0, info.GIDMap.Length, info.GIDMap.ReadError, info.GIDMap.ParseError, info.GIDMap.RawEvidence) + logging.DebugStep(logger, "privilege context detection", "systemd container: path=%s ok=%v value=%q read_err=%q", + info.SystemdContainer.Source, info.SystemdContainer.OK, strings.TrimSpace(info.SystemdContainer.Value), info.SystemdContainer.ReadError) + logging.DebugStep(logger, "privilege context detection", "computed: detected=%v details=%q", info.Detected, info.Details) + done(nil) +} diff --git a/internal/environment/unprivileged.go b/internal/environment/unprivileged.go index 0e96dcd..b4a397c 100644 --- a/internal/environment/unprivileged.go +++ b/internal/environment/unprivileged.go @@ -1,7 +1,9 @@ package environment import ( + "errors" "fmt" + "os" "strconv" "strings" ) @@ -12,6 +14,26 @@ const ( systemdContainerPath = "/run/systemd/container" ) +// IDMapOutsideZeroInfo describes the mapping information for inside ID 0 +// parsed from /proc/self/{uid_map,gid_map}. +type IDMapOutsideZeroInfo struct { + OK bool + Outside0 int64 + Length int64 + ReadError string + ParseError string + SourcePath string + RawEvidence string // best-effort, sanitized single-line hint +} + +// FileValueInfo describes a best-effort file read with a trimmed value. +type FileValueInfo struct { + OK bool + Value string + ReadError string + Source string +} + // UnprivilegedContainerInfo describes whether the current process appears to be // running in an unprivileged (shifted user namespace) environment. // @@ -19,6 +41,10 @@ const ( type UnprivilegedContainerInfo struct { Detected bool Details string + + UIDMap IDMapOutsideZeroInfo + GIDMap IDMapOutsideZeroInfo + SystemdContainer FileValueInfo } // DetectUnprivilegedContainer detects whether the current process appears to be @@ -30,40 +56,46 @@ type UnprivilegedContainerInfo struct { // // The return value is intentionally best-effort and never returns an error. func DetectUnprivilegedContainer() UnprivilegedContainerInfo { - uidOutside0, uidLen, uidOK := readIDMapOutsideZero(selfUIDMapPath) - gidOutside0, gidLen, gidOK := readIDMapOutsideZero(selfGIDMapPath) + uidInfo := readIDMapOutsideZeroInfo(selfUIDMapPath) + gidInfo := readIDMapOutsideZeroInfo(selfGIDMapPath) + containerInfo := readFileValueInfo(systemdContainerPath) - detected := (uidOK && uidOutside0 != 0) || (gidOK && gidOutside0 != 0) + detected := (uidInfo.OK && uidInfo.Outside0 != 0) || (gidInfo.OK && gidInfo.Outside0 != 0) details := make([]string, 0, 3) - if uidOK { - details = append(details, fmt.Sprintf("uid_map=0->%d(len=%d)", uidOutside0, uidLen)) - } else { - details = append(details, "uid_map=unavailable") - } - if gidOK { - details = append(details, fmt.Sprintf("gid_map=0->%d(len=%d)", gidOutside0, gidLen)) - } else { - details = append(details, "gid_map=unavailable") - } + details = append(details, formatIDMapDetails("uid_map", uidInfo)) + details = append(details, formatIDMapDetails("gid_map", gidInfo)) + details = append(details, formatFileValueDetails("container", containerInfo)) - if container := strings.TrimSpace(readAndTrim(systemdContainerPath)); container != "" { - details = append(details, fmt.Sprintf("container=%s", container)) - } - - out := UnprivilegedContainerInfo{Detected: detected} - if len(details) > 0 { - out.Details = strings.Join(details, ", ") + out := UnprivilegedContainerInfo{ + Detected: detected, + UIDMap: uidInfo, + GIDMap: gidInfo, + SystemdContainer: containerInfo, } + out.Details = strings.Join(details, ", ") return out } -func readIDMapOutsideZero(path string) (outside0, length int64, ok bool) { +func readIDMapOutsideZeroInfo(path string) IDMapOutsideZeroInfo { + info := IDMapOutsideZeroInfo{SourcePath: path} data, err := readFileFunc(path) if err != nil { - return 0, 0, false + info.ReadError = summarizeReadError(err) + return info + } + + outside0, length, ok := parseIDMapOutsideZero(string(data)) + if ok { + info.OK = true + info.Outside0 = outside0 + info.Length = length + info.RawEvidence = fmt.Sprintf("0 %d %d", outside0, length) + return info } - return parseIDMapOutsideZero(string(data)) + + info.ParseError = "no mapping for inside ID 0" + return info } func parseIDMapOutsideZero(content string) (outside0, length int64, ok bool) { @@ -91,3 +123,63 @@ func parseIDMapOutsideZero(content string) (outside0, length int64, ok bool) { } return 0, 0, false } + +func readFileValueInfo(path string) FileValueInfo { + info := FileValueInfo{Source: path} + data, err := readFileFunc(path) + if err != nil { + info.ReadError = summarizeReadError(err) + return info + } + info.OK = true + info.Value = strings.TrimSpace(string(data)) + return info +} + +func summarizeReadError(err error) string { + if err == nil { + return "" + } + switch { + case errors.Is(err, os.ErrNotExist): + return "not found" + case errors.Is(err, os.ErrPermission): + return "permission denied" + default: + // Keep it stable; avoid leaking potentially noisy OS-specific details. + return "error" + } +} + +func formatIDMapDetails(label string, info IDMapOutsideZeroInfo) string { + if strings.TrimSpace(label) == "" { + label = "id_map" + } + switch { + case info.OK: + return fmt.Sprintf("%s=0->%d(len=%d)", label, info.Outside0, info.Length) + case info.ReadError != "": + return fmt.Sprintf("%s=unavailable(err=%s)", label, info.ReadError) + case info.ParseError != "": + return fmt.Sprintf("%s=unparseable(err=%s)", label, info.ParseError) + default: + return fmt.Sprintf("%s=unavailable", label) + } +} + +func formatFileValueDetails(label string, info FileValueInfo) string { + label = strings.TrimSpace(label) + if label == "" { + label = "value" + } + switch { + case info.OK && strings.TrimSpace(info.Value) != "": + return fmt.Sprintf("%s=%s", label, strings.TrimSpace(info.Value)) + case info.OK: + return fmt.Sprintf("%s=empty", label) + case info.ReadError != "": + return fmt.Sprintf("%s=unavailable(err=%s)", label, info.ReadError) + default: + return fmt.Sprintf("%s=unavailable", label) + } +} diff --git a/internal/environment/unprivileged_test.go b/internal/environment/unprivileged_test.go index 2cb55c1..b41164e 100644 --- a/internal/environment/unprivileged_test.go +++ b/internal/environment/unprivileged_test.go @@ -2,6 +2,7 @@ package environment import ( "errors" + "os" "strings" "testing" ) @@ -70,6 +71,15 @@ func TestDetectUnprivilegedContainer(t *testing.T) { if !info.Detected { t.Fatalf("Detected=false, want true (details=%q)", info.Details) } + if !info.UIDMap.OK || info.UIDMap.Outside0 != 100000 { + t.Fatalf("unexpected UIDMap: ok=%v outside0=%d details=%q", info.UIDMap.OK, info.UIDMap.Outside0, info.Details) + } + if !info.GIDMap.OK || info.GIDMap.Outside0 != 100000 { + t.Fatalf("unexpected GIDMap: ok=%v outside0=%d details=%q", info.GIDMap.OK, info.GIDMap.Outside0, info.Details) + } + if !info.SystemdContainer.OK || info.SystemdContainer.Value != "lxc" { + t.Fatalf("unexpected SystemdContainer: ok=%v value=%q details=%q", info.SystemdContainer.OK, info.SystemdContainer.Value, info.Details) + } if !strings.Contains(info.Details, "uid_map=0->100000") { t.Fatalf("expected uid_map details, got %q", info.Details) } @@ -99,5 +109,74 @@ func TestDetectUnprivilegedContainer(t *testing.T) { if info.Detected { t.Fatalf("Detected=true, want false (details=%q)", info.Details) } + if !info.UIDMap.OK || info.UIDMap.Outside0 != 0 { + t.Fatalf("unexpected UIDMap: ok=%v outside0=%d details=%q", info.UIDMap.OK, info.UIDMap.Outside0, info.Details) + } + if !info.GIDMap.OK || info.GIDMap.Outside0 != 0 { + t.Fatalf("unexpected GIDMap: ok=%v outside0=%d details=%q", info.GIDMap.OK, info.GIDMap.Outside0, info.Details) + } + }) + + t.Run("maps unavailable", func(t *testing.T) { + setValue(t, &readFileFunc, func(path string) ([]byte, error) { + switch path { + case selfUIDMapPath, selfGIDMapPath, systemdContainerPath: + return nil, os.ErrNotExist + default: + return nil, errors.New("not found") + } + }) + + info := DetectUnprivilegedContainer() + if info.Detected { + t.Fatalf("Detected=true, want false (details=%q)", info.Details) + } + if info.UIDMap.OK || info.GIDMap.OK { + t.Fatalf("expected UID/GID maps to be unavailable (details=%q)", info.Details) + } + if info.SystemdContainer.OK { + t.Fatalf("expected container to be unavailable (details=%q)", info.Details) + } + if !strings.Contains(info.Details, "uid_map=unavailable(err=not found)") { + t.Fatalf("expected uid_map unavailable detail, got %q", info.Details) + } + if !strings.Contains(info.Details, "gid_map=unavailable(err=not found)") { + t.Fatalf("expected gid_map unavailable detail, got %q", info.Details) + } + if !strings.Contains(info.Details, "container=unavailable(err=not found)") { + t.Fatalf("expected container unavailable detail, got %q", info.Details) + } + }) + + t.Run("unparseable uid_map falls back to gid_map", func(t *testing.T) { + setValue(t, &readFileFunc, func(path string) ([]byte, error) { + switch path { + case selfUIDMapPath: + return []byte("not a map\n"), nil + case selfGIDMapPath: + return []byte("0 100000 65536\n"), nil + case systemdContainerPath: + return []byte("\n"), nil + default: + return nil, errors.New("not found") + } + }) + + info := DetectUnprivilegedContainer() + if !info.Detected { + t.Fatalf("Detected=false, want true (details=%q)", info.Details) + } + if info.UIDMap.OK || info.UIDMap.ParseError == "" { + t.Fatalf("expected UIDMap parse error, got ok=%v parseErr=%q (details=%q)", info.UIDMap.OK, info.UIDMap.ParseError, info.Details) + } + if !info.GIDMap.OK || info.GIDMap.Outside0 != 100000 { + t.Fatalf("unexpected GIDMap: ok=%v outside0=%d (details=%q)", info.GIDMap.OK, info.GIDMap.Outside0, info.Details) + } + if !strings.Contains(info.Details, "uid_map=unparseable") { + t.Fatalf("expected uid_map unparseable detail, got %q", info.Details) + } + if !strings.Contains(info.Details, "container=empty") { + t.Fatalf("expected empty container detail, got %q", info.Details) + } }) } diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 4eb71f7..271e88c 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -202,6 +202,9 @@ type Orchestrator struct { versionUpdateAvailable bool updateCurrentVersion string updateLatestVersion string + + // Unprivileged container context (computed once by CLI and injected into collectors). + unprivilegedContainerDetector func() (bool, string) } const tempDirCleanupAge = 24 * time.Hour @@ -436,6 +439,31 @@ func (o *Orchestrator) SetTempDirRegistry(reg *TempDirRegistry) { o.tempRegistry = reg } +// SetUnprivilegedContainerContext injects the precomputed unprivileged-container +// detection result into the orchestrator so that collectors can reuse it without +// re-reading /proc and related files. +// +// The "details" string is intended for DEBUG logs. +func (o *Orchestrator) SetUnprivilegedContainerContext(detected bool, details string) { + if o == nil { + return + } + d := detected + s := strings.TrimSpace(details) + o.unprivilegedContainerDetector = func() (bool, string) { + return d, s + } +} + +func (o *Orchestrator) collectorDeps() backup.CollectorDeps { + if o == nil || o.unprivilegedContainerDetector == nil { + return backup.CollectorDeps{} + } + return backup.CollectorDeps{ + DetectUnprivilegedContainer: o.unprivilegedContainerDetector, + } +} + func (o *Orchestrator) describeTelegramConfig() string { return describeTelegramStatus(o.cfg) } @@ -639,7 +667,7 @@ func (o *Orchestrator) RunGoBackup(ctx context.Context, pType types.ProxmoxType, } } - collector := backup.NewCollector(o.logger, collectorConfig, tempDir, pType, o.dryRun) + collector := backup.NewCollectorWithDeps(o.logger, collectorConfig, tempDir, pType, o.dryRun, o.collectorDeps()) o.logger.Debug("Starting collector run (type=%s)", pType) if err := collector.CollectAll(ctx); err != nil { diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index b4400e1..03a2c4e 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -314,6 +314,30 @@ func TestOrchestrator_SetUpdateInfo(t *testing.T) { nilOrch.SetUpdateInfo(true, "x", "y") // should not panic } +func TestOrchestrator_SetUnprivilegedContainerContext(t *testing.T) { + orch := &Orchestrator{} + deps := orch.collectorDeps() + if deps.DetectUnprivilegedContainer != nil { + t.Fatal("expected nil DetectUnprivilegedContainer by default") + } + + orch.SetUnprivilegedContainerContext(true, " sentinel ") + deps = orch.collectorDeps() + if deps.DetectUnprivilegedContainer == nil { + t.Fatal("expected DetectUnprivilegedContainer to be set") + } + detected, details := deps.DetectUnprivilegedContainer() + if !detected { + t.Fatalf("detected=false; want true (details=%q)", details) + } + if details != "sentinel" { + t.Fatalf("details=%q; want %q", details, "sentinel") + } + + var nilOrch *Orchestrator + nilOrch.SetUnprivilegedContainerContext(true, "x") // should not panic +} + // TestOrchestrator_SetChecker tests SetChecker func TestOrchestrator_SetChecker(t *testing.T) { logger := logging.New(types.LogLevelInfo, false) From d8bdc03b07141fd5116c549c7d66ddba6bba6dd3 Mon Sep 17 00:00:00 2001 From: Damiano <71268257+tis24dev@users.noreply.github.com> Date: Fri, 27 Feb 2026 00:54:35 +0100 Subject: [PATCH 4/4] Detect limited-privilege contexts beyond shifted userns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the privilege-context detector to also use container signals (systemd container, container env, docker/podman markers, cgroup hints) and non-root EUID. Improve the startup INFO Privilege context: ... line while keeping full evidence in DEBUG. Update the privilege-sensitive SKIP hint and related docs/tests to match the broader “limited privileges” semantics. --- cmd/proxsave/userns_context.go | 32 ++- docs/TROUBLESHOOTING.md | 14 +- internal/backup/collector.go | 16 +- .../collector_privilege_sensitive_test.go | 24 +- internal/backup/collector_unprivileged.go | 4 +- internal/environment/unprivileged.go | 228 +++++++++++++++++- internal/environment/unprivileged_test.go | 183 +++++++++++++- 7 files changed, 450 insertions(+), 51 deletions(-) diff --git a/cmd/proxsave/userns_context.go b/cmd/proxsave/userns_context.go index 5bc33f9..45165e2 100644 --- a/cmd/proxsave/userns_context.go +++ b/cmd/proxsave/userns_context.go @@ -10,23 +10,30 @@ import ( func logUserNamespaceContext(logger *logging.Logger, info environment.UnprivilegedContainerInfo) { mode := "unknown" note := "" + shifted := (info.UIDMap.OK && info.UIDMap.Outside0 != 0) || (info.GIDMap.OK && info.GIDMap.Outside0 != 0) + container := strings.TrimSpace(info.ContainerRuntime) + inContainer := container != "" + nonRoot := info.EUID != 0 + switch { - case info.Detected: + case shifted: mode = "unprivileged/rootless" note = " (some inventory may be skipped)" + case nonRoot: + mode = "non-root" + note = " (some inventory may be skipped)" + case inContainer: + mode = "container" + note = " (some inventory may be skipped)" case info.UIDMap.OK || info.GIDMap.OK: mode = "privileged" default: note = " (cannot infer; some inventory may be skipped)" } - container := "" - if info.SystemdContainer.OK { - container = strings.TrimSpace(info.SystemdContainer.Value) - } containerHint := "" if container != "" { - containerHint = " (container=" + container + ")" + containerHint = " (runtime=" + container + ")" } logging.Info("Privilege context: %s%s%s", mode, containerHint, note) @@ -38,6 +45,17 @@ func logUserNamespaceContext(logger *logging.Logger, info environment.Unprivileg info.GIDMap.SourcePath, info.GIDMap.OK, info.GIDMap.Outside0, info.GIDMap.Length, info.GIDMap.ReadError, info.GIDMap.ParseError, info.GIDMap.RawEvidence) logging.DebugStep(logger, "privilege context detection", "systemd container: path=%s ok=%v value=%q read_err=%q", info.SystemdContainer.Source, info.SystemdContainer.OK, strings.TrimSpace(info.SystemdContainer.Value), info.SystemdContainer.ReadError) - logging.DebugStep(logger, "privilege context detection", "computed: detected=%v details=%q", info.Detected, info.Details) + logging.DebugStep(logger, "privilege context detection", "env container: key=%q set=%v value=%q", + info.EnvContainer.Key, info.EnvContainer.Set, strings.TrimSpace(info.EnvContainer.Value)) + logging.DebugStep(logger, "privilege context detection", "marker: path=%s ok=%v present=%v stat_err=%q", + info.DockerMarker.Source, info.DockerMarker.OK, info.DockerMarker.Present, info.DockerMarker.StatError) + logging.DebugStep(logger, "privilege context detection", "marker: path=%s ok=%v present=%v stat_err=%q", + info.PodmanMarker.Source, info.PodmanMarker.OK, info.PodmanMarker.Present, info.PodmanMarker.StatError) + logging.DebugStep(logger, "privilege context detection", "cgroup hint: path=%s ok=%v value=%q read_err=%q", + info.Proc1CgroupHint.Source, info.Proc1CgroupHint.OK, strings.TrimSpace(info.Proc1CgroupHint.Value), info.Proc1CgroupHint.ReadError) + logging.DebugStep(logger, "privilege context detection", "cgroup hint: path=%s ok=%v value=%q read_err=%q", + info.SelfCgroupHint.Source, info.SelfCgroupHint.OK, strings.TrimSpace(info.SelfCgroupHint.Value), info.SelfCgroupHint.ReadError) + logging.DebugStep(logger, "privilege context detection", "computed: detected=%v shifted=%v euid=%d container=%q container_src=%q details=%q", + info.Detected, shifted, info.EUID, container, strings.TrimSpace(info.ContainerSource), info.Details) done(nil) } diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 4158f24..dda54ba 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -168,14 +168,14 @@ COMPRESSION_TYPE=xz # Valid: xz, zstd, gzip, bzip2, lz4 --- -#### Notice: `SKIP ... Expected in unprivileged containers` (LXC/rootless) +#### Notice: `SKIP ... Expected with limited privileges` (containers/non-root) **Symptoms**: -- Running ProxSave inside an **unprivileged** LXC container (or a rootless container) produces log lines like: - - `SKIP Skipping Hardware DMI information: DMI tables not accessible (Expected in unprivileged containers).` - - `SKIP Skipping Block device identifiers (blkid): block devices not accessible (restore hint: fstab remap may be limited) (Expected in unprivileged containers).` +- Running ProxSave in an environment with **limited privileges** (for example a container or non-root execution) can produce log lines like: + - `SKIP Skipping Hardware DMI information: DMI tables not accessible (Expected with limited privileges).` + - `SKIP Skipping Block device identifiers (blkid): block devices not accessible (restore hint: fstab remap may be limited) (Expected with limited privileges).` -**Cause**: In unprivileged containers, access to low-level system interfaces is intentionally restricted (for example `/dev/mem` and most block devices). Some inventory commands can fail even though the backup itself is working correctly. +**Cause**: With limited privileges, access to low-level system interfaces is intentionally restricted (for example `/dev/mem` and most block devices). Some inventory commands can fail even though the backup itself is working correctly. **Behavior**: - ProxSave still attempts the collection. @@ -186,7 +186,9 @@ COMPRESSION_TYPE=xz # Valid: xz, zstd, gzip, bzip2, lz4 - Hardware inventory output may be missing/empty. - If `blkid` is skipped, ProxSave restore may have **limited** ability to automatically remap `/etc/fstab` devices (UUID/PARTUUID/LABEL). You may need to review mounts manually during restore. -**How to verify** (shifted user namespace mapping): +**How to verify**: +- Check the startup log line: `INFO Privilege context: ...` +- If you suspect an unprivileged/shifted user namespace mapping: ```bash cat /proc/self/uid_map cat /proc/self/gid_map diff --git a/internal/backup/collector.go b/internal/backup/collector.go index c34f02b..466fed2 100644 --- a/internal/backup/collector.go +++ b/internal/backup/collector.go @@ -928,7 +928,7 @@ func (c *Collector) safeCmdOutput(ctx context.Context, cmd, output, description c.logger.Debug("Non-critical command output summary (safeCmdOutput): %s", summarizeCommandOutputText(outputText)) ctxInfo := c.depDetectUnprivilegedContainer() - c.logger.Debug("Unprivileged context evaluation: detected=%t details=%q", ctxInfo.Detected, strings.TrimSpace(ctxInfo.Details)) + c.logger.Debug("Privilege context evaluation: detected=%t details=%q", ctxInfo.Detected, strings.TrimSpace(ctxInfo.Details)) reason := "" if ctxInfo.Detected { @@ -937,14 +937,14 @@ func (c *Collector) safeCmdOutput(ctx context.Context, cmd, output, description reason = match.Reason c.logger.Debug("Privilege-sensitive classification: command=%q matched=%t match=%q reason=%q", cmdParts[0], reason != "", match.Match, reason) } else { - c.logger.Debug("Privilege-sensitive downgrade not considered: unprivileged context not detected (command=%q)", cmdParts[0]) + c.logger.Debug("Privilege-sensitive downgrade not considered: limited-privilege context not detected (command=%q)", cmdParts[0]) } if ctxInfo.Detected && reason != "" { c.logger.Debug("Downgrading WARNING->SKIP: description=%q cmd=%q exitCode=%d", description, cmdString, exitCode) - c.logger.Skip("Skipping %s: %s (Expected in unprivileged containers).", description, reason) - c.logger.Debug("SKIP context (privilege-sensitive): description=%q cmd=%q exitCode=%d err=%v unprivilegedDetails=%q", description, cmdString, exitCode, err, strings.TrimSpace(ctxInfo.Details)) + c.logger.Skip("Skipping %s: %s (Expected with limited privileges).", description, reason) + c.logger.Debug("SKIP context (privilege-sensitive): description=%q cmd=%q exitCode=%d err=%v contextDetails=%q", description, cmdString, exitCode, err, strings.TrimSpace(ctxInfo.Details)) c.logger.Debug("SKIP output summary for %s: %s", description, summarizeCommandOutputText(outputText)) return nil } @@ -1278,7 +1278,7 @@ func (c *Collector) captureCommandOutput(ctx context.Context, cmd, output, descr c.logger.Debug("Non-critical command output summary (captureCommandOutput): %s", summarizeCommandOutputText(outputText)) ctxInfo := c.depDetectUnprivilegedContainer() - c.logger.Debug("Unprivileged context evaluation: detected=%t details=%q", ctxInfo.Detected, strings.TrimSpace(ctxInfo.Details)) + c.logger.Debug("Privilege context evaluation: detected=%t details=%q", ctxInfo.Detected, strings.TrimSpace(ctxInfo.Details)) reason := "" if ctxInfo.Detected { @@ -1287,14 +1287,14 @@ func (c *Collector) captureCommandOutput(ctx context.Context, cmd, output, descr reason = match.Reason c.logger.Debug("Privilege-sensitive classification: command=%q matched=%t match=%q reason=%q", parts[0], reason != "", match.Match, reason) } else { - c.logger.Debug("Privilege-sensitive downgrade not considered: unprivileged context not detected (command=%q)", parts[0]) + c.logger.Debug("Privilege-sensitive downgrade not considered: limited-privilege context not detected (command=%q)", parts[0]) } if ctxInfo.Detected && reason != "" { c.logger.Debug("Downgrading WARNING->SKIP: description=%q cmd=%q exitCode=%d", description, cmdString, exitCode) - c.logger.Skip("Skipping %s: %s (Expected in unprivileged containers).", description, reason) - c.logger.Debug("SKIP context (privilege-sensitive): description=%q cmd=%q exitCode=%d err=%v unprivilegedDetails=%q", description, cmdString, exitCode, err, strings.TrimSpace(ctxInfo.Details)) + c.logger.Skip("Skipping %s: %s (Expected with limited privileges).", description, reason) + c.logger.Debug("SKIP context (privilege-sensitive): description=%q cmd=%q exitCode=%d err=%v contextDetails=%q", description, cmdString, exitCode, err, strings.TrimSpace(ctxInfo.Details)) c.logger.Debug("SKIP output summary for %s: %s", description, summarizeCommandOutputText(outputText)) return nil, nil } diff --git a/internal/backup/collector_privilege_sensitive_test.go b/internal/backup/collector_privilege_sensitive_test.go index 6e65e26..ff7925f 100644 --- a/internal/backup/collector_privilege_sensitive_test.go +++ b/internal/backup/collector_privilege_sensitive_test.go @@ -13,7 +13,7 @@ import ( "github.com/tis24dev/proxsave/internal/types" ) -func TestSafeCmdOutput_Unprivileged_DowngradesDmidecodeToSkip(t *testing.T) { +func TestSafeCmdOutput_LimitedPrivileges_DowngradesDmidecodeToSkip(t *testing.T) { buf := &bytes.Buffer{} logger := logging.New(types.LogLevelDebug, false) logger.SetOutput(buf) @@ -45,15 +45,15 @@ func TestSafeCmdOutput_Unprivileged_DowngradesDmidecodeToSkip(t *testing.T) { if !strings.Contains(logText, "] SKIP") { t.Fatalf("expected SKIP log line, got: %s", logText) } - if !strings.Contains(logText, "Expected in unprivileged containers") { - t.Fatalf("expected unprivileged hint in logs, got: %s", logText) + if !strings.Contains(logText, "Expected with limited privileges") { + t.Fatalf("expected limited-privileges hint in logs, got: %s", logText) } if !strings.Contains(logText, "DMI tables not accessible") { t.Fatalf("expected reason in logs, got: %s", logText) } } -func TestCaptureCommandOutput_Unprivileged_DowngradesBlkidToSkipWithRestoreHint(t *testing.T) { +func TestCaptureCommandOutput_LimitedPrivileges_DowngradesBlkidToSkipWithRestoreHint(t *testing.T) { buf := &bytes.Buffer{} logger := logging.New(types.LogLevelDebug, false) logger.SetOutput(buf) @@ -88,15 +88,15 @@ func TestCaptureCommandOutput_Unprivileged_DowngradesBlkidToSkipWithRestoreHint( if !strings.Contains(logText, "] SKIP") { t.Fatalf("expected SKIP log line, got: %s", logText) } - if !strings.Contains(logText, "Expected in unprivileged containers") { - t.Fatalf("expected unprivileged hint in logs, got: %s", logText) + if !strings.Contains(logText, "Expected with limited privileges") { + t.Fatalf("expected limited-privileges hint in logs, got: %s", logText) } if !strings.Contains(logText, "restore hint: fstab remap may be limited") { t.Fatalf("expected restore hint in logs, got: %s", logText) } } -func TestSafeCmdOutput_Unprivileged_DowngradesSensorsToSkip(t *testing.T) { +func TestSafeCmdOutput_LimitedPrivileges_DowngradesSensorsToSkip(t *testing.T) { buf := &bytes.Buffer{} logger := logging.New(types.LogLevelDebug, false) logger.SetOutput(buf) @@ -128,15 +128,15 @@ func TestSafeCmdOutput_Unprivileged_DowngradesSensorsToSkip(t *testing.T) { if !strings.Contains(logText, "] SKIP") { t.Fatalf("expected SKIP log line, got: %s", logText) } - if !strings.Contains(logText, "Expected in unprivileged containers") { - t.Fatalf("expected unprivileged hint in logs, got: %s", logText) + if !strings.Contains(logText, "Expected with limited privileges") { + t.Fatalf("expected limited-privileges hint in logs, got: %s", logText) } if !strings.Contains(logText, "hardware sensors not accessible") { t.Fatalf("expected reason in logs, got: %s", logText) } } -func TestSafeCmdOutput_Unprivileged_DowngradesSmartctlToSkip(t *testing.T) { +func TestSafeCmdOutput_LimitedPrivileges_DowngradesSmartctlToSkip(t *testing.T) { buf := &bytes.Buffer{} logger := logging.New(types.LogLevelDebug, false) logger.SetOutput(buf) @@ -168,8 +168,8 @@ func TestSafeCmdOutput_Unprivileged_DowngradesSmartctlToSkip(t *testing.T) { if !strings.Contains(logText, "] SKIP") { t.Fatalf("expected SKIP log line, got: %s", logText) } - if !strings.Contains(logText, "Expected in unprivileged containers") { - t.Fatalf("expected unprivileged hint in logs, got: %s", logText) + if !strings.Contains(logText, "Expected with limited privileges") { + t.Fatalf("expected limited-privileges hint in logs, got: %s", logText) } if !strings.Contains(logText, "SMART devices not accessible") { t.Fatalf("expected reason in logs, got: %s", logText) diff --git a/internal/backup/collector_unprivileged.go b/internal/backup/collector_unprivileged.go index 7eb67f4..4431d95 100644 --- a/internal/backup/collector_unprivileged.go +++ b/internal/backup/collector_unprivileged.go @@ -20,7 +20,7 @@ func (c *Collector) depDetectUnprivilegedContainer() unprivilegedContainerContex Details: details, } if c.logger != nil { - c.logger.Debug("Unprivileged container detection (deps override): detected=%t details=%q", c.unprivilegedCtx.Detected, c.unprivilegedCtx.Details) + c.logger.Debug("Privilege context detection (deps override): detected=%t details=%q", c.unprivilegedCtx.Detected, c.unprivilegedCtx.Details) } return } @@ -31,7 +31,7 @@ func (c *Collector) depDetectUnprivilegedContainer() unprivilegedContainerContex Details: info.Details, } if c.logger != nil { - c.logger.Debug("Unprivileged container detection: detected=%t details=%q", c.unprivilegedCtx.Detected, c.unprivilegedCtx.Details) + c.logger.Debug("Privilege context detection: detected=%t details=%q", c.unprivilegedCtx.Detected, c.unprivilegedCtx.Details) } }) diff --git a/internal/environment/unprivileged.go b/internal/environment/unprivileged.go index b4a397c..ed27be8 100644 --- a/internal/environment/unprivileged.go +++ b/internal/environment/unprivileged.go @@ -12,6 +12,17 @@ const ( selfUIDMapPath = "/proc/self/uid_map" selfGIDMapPath = "/proc/self/gid_map" systemdContainerPath = "/run/systemd/container" + + proc1CgroupPath = "/proc/1/cgroup" + selfCgroupPath = "/proc/self/cgroup" + + dockerMarkerPath = "/.dockerenv" + podmanMarkerPath = "/run/.containerenv" +) + +var ( + getEUIDFunc = os.Geteuid + lookupEnvFunc = os.LookupEnv ) // IDMapOutsideZeroInfo describes the mapping information for inside ID 0 @@ -34,10 +45,28 @@ type FileValueInfo struct { Source string } +// FilePresenceInfo describes whether a file exists, based on a best-effort +// stat call. +type FilePresenceInfo struct { + OK bool + Present bool + StatError string + Source string +} + +// EnvVarInfo describes a best-effort environment variable lookup. +type EnvVarInfo struct { + Key string + Set bool + Value string +} + // UnprivilegedContainerInfo describes whether the current process appears to be -// running in an unprivileged (shifted user namespace) environment. +// running in an environment with limited system privileges. // -// This is commonly true for unprivileged LXC containers and rootless containers. +// This can be true for unprivileged LXC containers (shifted user namespace), +// rootless containers, non-root execution, and many container runtimes where +// access to low-level system interfaces is intentionally restricted. type UnprivilegedContainerInfo struct { Detected bool Details string @@ -45,33 +74,71 @@ type UnprivilegedContainerInfo struct { UIDMap IDMapOutsideZeroInfo GIDMap IDMapOutsideZeroInfo SystemdContainer FileValueInfo + + EnvContainer EnvVarInfo + + DockerMarker FilePresenceInfo + PodmanMarker FilePresenceInfo + + Proc1CgroupHint FileValueInfo + SelfCgroupHint FileValueInfo + + ContainerRuntime string + ContainerSource string + + EUID int } // DetectUnprivilegedContainer detects whether the current process appears to be -// running in an unprivileged (shifted user namespace) environment. +// running in an environment with limited system privileges. // // Detection is based on /proc/self/{uid_map,gid_map}. When the mapping for // inside-ID 0 maps to a non-zero outside-ID, the process is in a shifted user // namespace and likely lacks low-level hardware/block-device privileges. // +// In addition, best-effort container heuristics are used to reduce false +// negatives on container runtimes where uid_map is not shifted (for example, +// rootful Docker/Podman or privileged LXC with restricted device access). +// // The return value is intentionally best-effort and never returns an error. func DetectUnprivilegedContainer() UnprivilegedContainerInfo { uidInfo := readIDMapOutsideZeroInfo(selfUIDMapPath) gidInfo := readIDMapOutsideZeroInfo(selfGIDMapPath) - containerInfo := readFileValueInfo(systemdContainerPath) + systemdContainer := readFileValueInfo(systemdContainerPath) + envContainer := readEnvVarInfo("container") + dockerMarker := readFilePresenceInfo(dockerMarkerPath) + podmanMarker := readFilePresenceInfo(podmanMarkerPath) + proc1CgroupHint := readCgroupHintInfo(proc1CgroupPath) + selfCgroupHint := readCgroupHintInfo(selfCgroupPath) - detected := (uidInfo.OK && uidInfo.Outside0 != 0) || (gidInfo.OK && gidInfo.Outside0 != 0) - details := make([]string, 0, 3) + containerRuntime, containerSource := computeContainerRuntime(systemdContainer, envContainer, dockerMarker, podmanMarker, proc1CgroupHint, selfCgroupHint) + containerRuntime = sanitizeToken(containerRuntime) + containerSource = sanitizeToken(containerSource) + euid := getEUIDFunc() + shifted := (uidInfo.OK && uidInfo.Outside0 != 0) || (gidInfo.OK && gidInfo.Outside0 != 0) + detected := shifted || euid != 0 || strings.TrimSpace(containerRuntime) != "" + + details := make([]string, 0, 6) details = append(details, formatIDMapDetails("uid_map", uidInfo)) details = append(details, formatIDMapDetails("gid_map", gidInfo)) - details = append(details, formatFileValueDetails("container", containerInfo)) + details = append(details, formatSimpleDetails("container", containerRuntime, "none")) + details = append(details, formatSimpleDetails("container_src", containerSource, "none")) + details = append(details, formatSimpleDetails("euid", fmt.Sprintf("%d", euid), "unknown")) out := UnprivilegedContainerInfo{ Detected: detected, UIDMap: uidInfo, GIDMap: gidInfo, - SystemdContainer: containerInfo, + SystemdContainer: systemdContainer, + EnvContainer: envContainer, + DockerMarker: dockerMarker, + PodmanMarker: podmanMarker, + Proc1CgroupHint: proc1CgroupHint, + SelfCgroupHint: selfCgroupHint, + ContainerRuntime: containerRuntime, + ContainerSource: containerSource, + EUID: euid, } out.Details = strings.Join(details, ", ") return out @@ -136,6 +203,107 @@ func readFileValueInfo(path string) FileValueInfo { return info } +func readFilePresenceInfo(path string) FilePresenceInfo { + info := FilePresenceInfo{Source: path} + if strings.TrimSpace(path) == "" { + info.StatError = "invalid path" + return info + } + _, err := statFunc(path) + switch { + case err == nil: + info.OK = true + info.Present = true + return info + case errors.Is(err, os.ErrNotExist): + info.OK = true + info.Present = false + return info + default: + info.StatError = summarizeReadError(err) + return info + } +} + +func readEnvVarInfo(key string) EnvVarInfo { + key = strings.TrimSpace(key) + if key == "" { + return EnvVarInfo{} + } + value, ok := lookupEnvFunc(key) + return EnvVarInfo{ + Key: key, + Set: ok, + Value: strings.TrimSpace(value), + } +} + +func readCgroupHintInfo(path string) FileValueInfo { + info := FileValueInfo{Source: path} + data, err := readFileFunc(path) + if err != nil { + info.ReadError = summarizeReadError(err) + return info + } + info.OK = true + info.Value = strings.TrimSpace(containerHintFromCgroup(string(data))) + return info +} + +func computeContainerRuntime(systemdContainer FileValueInfo, envContainer EnvVarInfo, dockerMarker FilePresenceInfo, podmanMarker FilePresenceInfo, proc1CgroupHint FileValueInfo, selfCgroupHint FileValueInfo) (runtime, source string) { + if systemdContainer.OK { + if v := strings.TrimSpace(systemdContainer.Value); v != "" { + return v, "systemd" + } + } + + if envContainer.Set { + if v := strings.TrimSpace(envContainer.Value); v != "" { + return v, "env" + } + } + + if dockerMarker.OK && dockerMarker.Present { + return "docker", "marker" + } + if podmanMarker.OK && podmanMarker.Present { + return "podman", "marker" + } + + if proc1CgroupHint.OK { + if v := strings.TrimSpace(proc1CgroupHint.Value); v != "" { + return v, "cgroup" + } + } + if selfCgroupHint.OK { + if v := strings.TrimSpace(selfCgroupHint.Value); v != "" { + return v, "cgroup" + } + } + + return "", "" +} + +func containerHintFromCgroup(content string) string { + lower := strings.ToLower(content) + switch { + case strings.Contains(lower, "kubepods"): + return "kubernetes" + case strings.Contains(lower, "docker"): + return "docker" + case strings.Contains(lower, "libpod"): + return "podman" + case strings.Contains(lower, "podman"): + return "podman" + case strings.Contains(lower, "lxc"): + return "lxc" + case strings.Contains(lower, "containerd"): + return "containerd" + default: + return "" + } +} + func summarizeReadError(err error) string { if err == nil { return "" @@ -183,3 +351,47 @@ func formatFileValueDetails(label string, info FileValueInfo) string { return fmt.Sprintf("%s=unavailable", label) } } + +func formatSimpleDetails(label, value, emptyValue string) string { + label = strings.TrimSpace(label) + if label == "" { + label = "value" + } + value = strings.TrimSpace(value) + if value == "" { + value = strings.TrimSpace(emptyValue) + } + if value == "" { + value = "unknown" + } + return fmt.Sprintf("%s=%s", label, value) +} + +func sanitizeToken(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + value = strings.ToLower(value) + + const maxLen = 64 + var b strings.Builder + b.Grow(len(value)) + for _, r := range value { + if b.Len() >= maxLen { + break + } + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= '0' && r <= '9': + b.WriteRune(r) + case r == '-' || r == '_' || r == '.' || r == ':' || r == '/': + b.WriteRune(r) + default: + // Normalize all other characters. + b.WriteByte('_') + } + } + return strings.Trim(b.String(), "_") +} diff --git a/internal/environment/unprivileged_test.go b/internal/environment/unprivileged_test.go index b41164e..b6dd4ad 100644 --- a/internal/environment/unprivileged_test.go +++ b/internal/environment/unprivileged_test.go @@ -5,8 +5,21 @@ import ( "os" "strings" "testing" + "time" ) +type fakeFileInfo struct { + name string + dir bool +} + +func (fi fakeFileInfo) Name() string { return fi.name } +func (fi fakeFileInfo) Size() int64 { return 0 } +func (fi fakeFileInfo) Mode() os.FileMode { return 0o644 } +func (fi fakeFileInfo) ModTime() time.Time { return time.Time{} } +func (fi fakeFileInfo) IsDir() bool { return fi.dir } +func (fi fakeFileInfo) Sys() any { return nil } + func TestParseIDMapOutsideZero(t *testing.T) { t.Run("privileged mapping", func(t *testing.T) { outside0, length, ok := parseIDMapOutsideZero("0 0 4294967295\n") @@ -53,6 +66,10 @@ func TestParseIDMapOutsideZero(t *testing.T) { } func TestDetectUnprivilegedContainer(t *testing.T) { + setValue(t, &getEUIDFunc, func() int { return 0 }) + setValue(t, &lookupEnvFunc, func(string) (string, bool) { return "", false }) + setValue(t, &statFunc, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + t.Run("shifted uid/gid maps", func(t *testing.T) { setValue(t, &readFileFunc, func(path string) ([]byte, error) { switch path { @@ -62,6 +79,8 @@ func TestDetectUnprivilegedContainer(t *testing.T) { return []byte("0 100000 65536\n"), nil case systemdContainerPath: return []byte("lxc\n"), nil + case proc1CgroupPath, selfCgroupPath: + return nil, os.ErrNotExist default: return nil, errors.New("not found") } @@ -89,9 +108,12 @@ func TestDetectUnprivilegedContainer(t *testing.T) { if !strings.Contains(info.Details, "container=lxc") { t.Fatalf("expected container details, got %q", info.Details) } + if !strings.Contains(info.Details, "container_src=systemd") { + t.Fatalf("expected container_src details, got %q", info.Details) + } }) - t.Run("privileged mapping", func(t *testing.T) { + t.Run("privileged mapping (but container detected)", func(t *testing.T) { setValue(t, &readFileFunc, func(path string) ([]byte, error) { switch path { case selfUIDMapPath: @@ -100,14 +122,16 @@ func TestDetectUnprivilegedContainer(t *testing.T) { return []byte("0 0 4294967295\n"), nil case systemdContainerPath: return []byte("lxc\n"), nil + case proc1CgroupPath, selfCgroupPath: + return nil, os.ErrNotExist default: return nil, errors.New("not found") } }) info := DetectUnprivilegedContainer() - if info.Detected { - t.Fatalf("Detected=true, want false (details=%q)", info.Details) + if !info.Detected { + t.Fatalf("Detected=false, want true (details=%q)", info.Details) } if !info.UIDMap.OK || info.UIDMap.Outside0 != 0 { t.Fatalf("unexpected UIDMap: ok=%v outside0=%d details=%q", info.UIDMap.OK, info.UIDMap.Outside0, info.Details) @@ -115,12 +139,18 @@ func TestDetectUnprivilegedContainer(t *testing.T) { if !info.GIDMap.OK || info.GIDMap.Outside0 != 0 { t.Fatalf("unexpected GIDMap: ok=%v outside0=%d details=%q", info.GIDMap.OK, info.GIDMap.Outside0, info.Details) } + if info.ContainerRuntime != "lxc" || info.ContainerSource != "systemd" { + t.Fatalf("unexpected container: runtime=%q source=%q details=%q", info.ContainerRuntime, info.ContainerSource, info.Details) + } + if !strings.Contains(info.Details, "container=lxc") { + t.Fatalf("expected container details, got %q", info.Details) + } }) t.Run("maps unavailable", func(t *testing.T) { setValue(t, &readFileFunc, func(path string) ([]byte, error) { switch path { - case selfUIDMapPath, selfGIDMapPath, systemdContainerPath: + case selfUIDMapPath, selfGIDMapPath, systemdContainerPath, proc1CgroupPath, selfCgroupPath: return nil, os.ErrNotExist default: return nil, errors.New("not found") @@ -143,8 +173,8 @@ func TestDetectUnprivilegedContainer(t *testing.T) { if !strings.Contains(info.Details, "gid_map=unavailable(err=not found)") { t.Fatalf("expected gid_map unavailable detail, got %q", info.Details) } - if !strings.Contains(info.Details, "container=unavailable(err=not found)") { - t.Fatalf("expected container unavailable detail, got %q", info.Details) + if !strings.Contains(info.Details, "container=none") { + t.Fatalf("expected container none detail, got %q", info.Details) } }) @@ -157,6 +187,8 @@ func TestDetectUnprivilegedContainer(t *testing.T) { return []byte("0 100000 65536\n"), nil case systemdContainerPath: return []byte("\n"), nil + case proc1CgroupPath, selfCgroupPath: + return nil, os.ErrNotExist default: return nil, errors.New("not found") } @@ -175,8 +207,143 @@ func TestDetectUnprivilegedContainer(t *testing.T) { if !strings.Contains(info.Details, "uid_map=unparseable") { t.Fatalf("expected uid_map unparseable detail, got %q", info.Details) } - if !strings.Contains(info.Details, "container=empty") { - t.Fatalf("expected empty container detail, got %q", info.Details) + if !strings.Contains(info.Details, "container=none") { + t.Fatalf("expected none container detail, got %q", info.Details) + } + }) + + t.Run("privileged host mapping (no container)", func(t *testing.T) { + setValue(t, &readFileFunc, func(path string) ([]byte, error) { + switch path { + case selfUIDMapPath: + return []byte("0 0 4294967295\n"), nil + case selfGIDMapPath: + return []byte("0 0 4294967295\n"), nil + case systemdContainerPath, proc1CgroupPath, selfCgroupPath: + return nil, os.ErrNotExist + default: + return nil, errors.New("not found") + } + }) + + info := DetectUnprivilegedContainer() + if info.Detected { + t.Fatalf("Detected=true, want false (details=%q)", info.Details) + } + if info.ContainerRuntime != "" || info.ContainerSource != "" { + t.Fatalf("expected empty container runtime/source, got runtime=%q source=%q (details=%q)", info.ContainerRuntime, info.ContainerSource, info.Details) + } + if !strings.Contains(info.Details, "container=none") { + t.Fatalf("expected container none detail, got %q", info.Details) + } + }) + + t.Run("docker marker implies restricted context", func(t *testing.T) { + setValue(t, &statFunc, func(path string) (os.FileInfo, error) { + if path == dockerMarkerPath { + return fakeFileInfo{name: "dockerenv"}, nil + } + return nil, os.ErrNotExist + }) + setValue(t, &readFileFunc, func(path string) ([]byte, error) { + switch path { + case selfUIDMapPath: + return []byte("0 0 4294967295\n"), nil + case selfGIDMapPath: + return []byte("0 0 4294967295\n"), nil + case systemdContainerPath, proc1CgroupPath, selfCgroupPath: + return nil, os.ErrNotExist + default: + return nil, errors.New("not found") + } + }) + + info := DetectUnprivilegedContainer() + if !info.Detected { + t.Fatalf("Detected=false, want true (details=%q)", info.Details) + } + if info.ContainerRuntime != "docker" || info.ContainerSource != "marker" { + t.Fatalf("unexpected container: runtime=%q source=%q details=%q", info.ContainerRuntime, info.ContainerSource, info.Details) + } + }) + + t.Run("cgroup hint implies restricted context", func(t *testing.T) { + setValue(t, &readFileFunc, func(path string) ([]byte, error) { + switch path { + case selfUIDMapPath: + return []byte("0 0 4294967295\n"), nil + case selfGIDMapPath: + return []byte("0 0 4294967295\n"), nil + case systemdContainerPath: + return nil, os.ErrNotExist + case proc1CgroupPath: + return []byte("0::/docker/abcdef\n"), nil + case selfCgroupPath: + return nil, os.ErrNotExist + default: + return nil, errors.New("not found") + } + }) + + info := DetectUnprivilegedContainer() + if !info.Detected { + t.Fatalf("Detected=false, want true (details=%q)", info.Details) + } + if info.ContainerRuntime != "docker" || info.ContainerSource != "cgroup" { + t.Fatalf("unexpected container: runtime=%q source=%q details=%q", info.ContainerRuntime, info.ContainerSource, info.Details) + } + }) + + t.Run("env container implies restricted context", func(t *testing.T) { + setValue(t, &lookupEnvFunc, func(key string) (string, bool) { + if key == "container" { + return "podman", true + } + return "", false + }) + setValue(t, &readFileFunc, func(path string) ([]byte, error) { + switch path { + case selfUIDMapPath: + return []byte("0 0 4294967295\n"), nil + case selfGIDMapPath: + return []byte("0 0 4294967295\n"), nil + case systemdContainerPath, proc1CgroupPath, selfCgroupPath: + return nil, os.ErrNotExist + default: + return nil, errors.New("not found") + } + }) + + info := DetectUnprivilegedContainer() + if !info.Detected { + t.Fatalf("Detected=false, want true (details=%q)", info.Details) + } + if info.ContainerRuntime != "podman" || info.ContainerSource != "env" { + t.Fatalf("unexpected container: runtime=%q source=%q details=%q", info.ContainerRuntime, info.ContainerSource, info.Details) + } + }) + + t.Run("non-root implies restricted context", func(t *testing.T) { + setValue(t, &getEUIDFunc, func() int { return 1000 }) + setValue(t, &readFileFunc, func(path string) ([]byte, error) { + switch path { + case selfUIDMapPath: + return []byte("0 0 4294967295\n"), nil + case selfGIDMapPath: + return []byte("0 0 4294967295\n"), nil + case systemdContainerPath, proc1CgroupPath, selfCgroupPath: + return nil, os.ErrNotExist + default: + return nil, errors.New("not found") + } + }) + + info := DetectUnprivilegedContainer() + if !info.Detected { + t.Fatalf("Detected=false, want true (details=%q)", info.Details) + } + if info.EUID != 1000 { + t.Fatalf("EUID=%d, want 1000 (details=%q)", info.EUID, info.Details) } }) }