Skip to content

Commit b7d9670

Browse files
authored
Sync dev to main (#163)
* 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. * 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. * 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. * Detect limited-privilege contexts beyond shifted userns 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.
1 parent b55fa52 commit b7d9670

20 files changed

Lines changed: 1919 additions & 68 deletions

cmd/proxsave/install.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/tis24dev/proxsave/internal/config"
1616
"github.com/tis24dev/proxsave/internal/identity"
1717
"github.com/tis24dev/proxsave/internal/logging"
18+
"github.com/tis24dev/proxsave/internal/notify"
1819
"github.com/tis24dev/proxsave/internal/orchestrator"
1920
"github.com/tis24dev/proxsave/internal/tui/wizard"
2021
"github.com/tis24dev/proxsave/internal/types"
@@ -95,6 +96,13 @@ func runInstall(ctx context.Context, configPath string, bootstrap *logging.Boots
9596
if err := runPostInstallAuditCLI(ctx, reader, execInfo.ExecPath, configPath, bootstrap); err != nil {
9697
return err
9798
}
99+
100+
// Telegram setup (centralized bot): if enabled, guide the user through pairing
101+
// and allow an explicit verification step with retry + skip.
102+
logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "telegram setup")
103+
if err := runTelegramSetupCLI(ctx, reader, baseDir, configPath, bootstrap); err != nil {
104+
return err
105+
}
98106
}
99107

100108
logging.DebugStepBootstrap(bootstrap, "install workflow (cli)", "finalizing symlinks and cron")
@@ -117,6 +125,123 @@ func runInstall(ctx context.Context, configPath string, bootstrap *logging.Boots
117125
return nil
118126
}
119127

128+
func runTelegramSetupCLI(ctx context.Context, reader *bufio.Reader, baseDir, configPath string, bootstrap *logging.BootstrapLogger) error {
129+
cfg, err := config.LoadConfig(configPath)
130+
if err != nil {
131+
if bootstrap != nil {
132+
bootstrap.Warning("Telegram setup: unable to load config (skipping): %v", err)
133+
}
134+
return nil
135+
}
136+
if cfg == nil || !cfg.TelegramEnabled {
137+
return nil
138+
}
139+
140+
mode := strings.ToLower(strings.TrimSpace(cfg.TelegramBotType))
141+
if mode == "" {
142+
mode = "centralized"
143+
}
144+
if mode == "personal" {
145+
// No centralized pairing check exists for personal mode.
146+
if bootstrap != nil {
147+
bootstrap.Info("Telegram setup: personal mode selected (no centralized pairing check)")
148+
}
149+
return nil
150+
}
151+
152+
fmt.Println("\n--- Telegram setup (optional) ---")
153+
fmt.Println("You enabled Telegram notifications (centralized bot).")
154+
155+
info, idErr := identity.Detect(baseDir, nil)
156+
if idErr != nil {
157+
fmt.Printf("WARNING: Unable to compute server identity (non-blocking): %v\n", idErr)
158+
if bootstrap != nil {
159+
bootstrap.Warning("Telegram setup: identity detection failed (non-blocking): %v", idErr)
160+
}
161+
return nil
162+
}
163+
164+
serverID := ""
165+
if info != nil {
166+
serverID = strings.TrimSpace(info.ServerID)
167+
}
168+
if serverID == "" {
169+
fmt.Println("WARNING: Server ID unavailable; skipping Telegram setup.")
170+
if bootstrap != nil {
171+
bootstrap.Warning("Telegram setup: server ID unavailable; skipping")
172+
}
173+
return nil
174+
}
175+
176+
fmt.Printf("Server ID: %s\n", serverID)
177+
if info != nil && strings.TrimSpace(info.IdentityFile) != "" {
178+
fmt.Printf("Identity file: %s\n", strings.TrimSpace(info.IdentityFile))
179+
}
180+
fmt.Println()
181+
fmt.Println("1) Open Telegram and start @ProxmoxAN_bot")
182+
fmt.Println("2) Send the Server ID above (digits only)")
183+
fmt.Println("3) Verify pairing (recommended)")
184+
fmt.Println()
185+
186+
check, err := promptYesNo(ctx, reader, "Check Telegram pairing now? [Y/n]: ", true)
187+
if err != nil {
188+
return wrapInstallError(err)
189+
}
190+
if !check {
191+
fmt.Println("Skipped verification. You can verify later by running proxsave.")
192+
if bootstrap != nil {
193+
bootstrap.Info("Telegram setup: verification skipped by user")
194+
}
195+
return nil
196+
}
197+
198+
serverHost := strings.TrimSpace(cfg.TelegramServerAPIHost)
199+
if serverHost == "" {
200+
serverHost = "https://bot.tis24.it:1443"
201+
}
202+
203+
attempts := 0
204+
for {
205+
attempts++
206+
status := notify.CheckTelegramRegistration(ctx, serverHost, serverID, nil)
207+
if status.Code == 200 && status.Error == nil {
208+
fmt.Println("✓ Telegram linked successfully.")
209+
if bootstrap != nil {
210+
bootstrap.Info("Telegram setup: verified (attempts=%d)", attempts)
211+
}
212+
return nil
213+
}
214+
215+
msg := strings.TrimSpace(status.Message)
216+
if msg == "" {
217+
msg = "Registration not active yet"
218+
}
219+
fmt.Printf("Telegram: %s\n", msg)
220+
switch status.Code {
221+
case 403, 409:
222+
fmt.Println("Hint: Start the bot, send the Server ID, then retry.")
223+
case 422:
224+
fmt.Println("Hint: The Server ID appears invalid. If this persists, re-run the installer.")
225+
default:
226+
if status.Error != nil {
227+
fmt.Printf("Hint: Check failed: %v\n", status.Error)
228+
}
229+
}
230+
231+
retry, err := promptYesNo(ctx, reader, "Check again? [y/N]: ", false)
232+
if err != nil {
233+
return wrapInstallError(err)
234+
}
235+
if !retry {
236+
fmt.Println("Verification not completed. You can retry later by running proxsave.")
237+
if bootstrap != nil {
238+
bootstrap.Info("Telegram setup: not verified (attempts=%d last=%d %s)", attempts, status.Code, msg)
239+
}
240+
return nil
241+
}
242+
}
243+
}
244+
120245
func runPostInstallAuditCLI(ctx context.Context, reader *bufio.Reader, execPath, configPath string, bootstrap *logging.BootstrapLogger) error {
121246
fmt.Println("\n--- Post-install check (optional) ---")
122247
run, err := promptYesNo(ctx, reader, "Run a dry-run to detect unused components and reduce warnings? [Y/n]: ", true)

cmd/proxsave/install_tui.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,34 @@ func runInstallTUI(ctx context.Context, configPath string, bootstrap *logging.Bo
208208
}
209209
}
210210

211+
// Telegram setup (centralized bot): if enabled during install, guide the user through
212+
// pairing and allow an explicit verification step with retry + skip.
213+
if wizardData != nil && (wizardData.NotificationMode == "telegram" || wizardData.NotificationMode == "both") {
214+
telegramRes, telegramErr := wizard.RunTelegramSetupWizard(ctx, baseDir, configPath, buildSig)
215+
if telegramErr != nil && bootstrap != nil {
216+
bootstrap.Warning("Telegram setup failed (non-blocking): %v", telegramErr)
217+
}
218+
if bootstrap != nil && telegramRes.Shown {
219+
if telegramRes.ConfigError != "" {
220+
bootstrap.Warning("Telegram setup: failed to load config (non-blocking): %s", telegramRes.ConfigError)
221+
}
222+
if telegramRes.IdentityDetectError != "" {
223+
bootstrap.Warning("Telegram setup: identity detection issue (non-blocking): %s", telegramRes.IdentityDetectError)
224+
}
225+
if telegramRes.TelegramMode == "personal" {
226+
bootstrap.Info("Telegram setup: personal mode selected (no centralized pairing check)")
227+
} else if telegramRes.Verified {
228+
bootstrap.Info("Telegram setup: verified (code=%d)", telegramRes.LastStatusCode)
229+
} else if telegramRes.SkippedVerification {
230+
bootstrap.Info("Telegram setup: verification skipped by user")
231+
} else if telegramRes.CheckAttempts > 0 {
232+
bootstrap.Info("Telegram setup: not verified (attempts=%d last=%d %s)", telegramRes.CheckAttempts, telegramRes.LastStatusCode, telegramRes.LastStatusMessage)
233+
} else {
234+
bootstrap.Info("Telegram setup: not verified (no check performed)")
235+
}
236+
}
237+
}
238+
211239
// Clean up legacy bash-based symlinks
212240
if bootstrap != nil {
213241
bootstrap.Info("Cleaning up legacy bash-based symlinks (if present)")

cmd/proxsave/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,8 @@ func run() int {
770770

771771
// Log environment info
772772
logging.Info("Environment: %s %s", envInfo.Type, envInfo.Version)
773+
unprivilegedInfo := environment.DetectUnprivilegedContainer()
774+
logUserNamespaceContext(logger, unprivilegedInfo)
773775
logging.Info("Backup enabled: %v", cfg.BackupEnabled)
774776
logging.Info("Debug level: %s", logLevel.String())
775777
logging.Info("Compression: %s (level %d, mode %s)", cfg.CompressionType, cfg.CompressionLevel, cfg.CompressionMode)
@@ -935,6 +937,7 @@ func run() int {
935937
logging.Step("Initializing backup orchestrator")
936938
orchInitDone := logging.DebugStart(logger, "orchestrator init", "dry_run=%v", dryRun)
937939
orch = orchestrator.New(logger, dryRun)
940+
orch.SetUnprivilegedContainerContext(unprivilegedInfo.Detected, unprivilegedInfo.Details)
938941
orch.SetVersion(toolVersion)
939942
orch.SetConfig(cfg)
940943
orch.SetIdentity(serverIDValue, serverMACValue)

cmd/proxsave/upgrade.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,18 @@ func runUpgrade(ctx context.Context, args *cli.Args, bootstrap *logging.Bootstra
126126

127127
bootstrap.Printf("Latest available version: %s (current: %s)", latestVersion, currentVersion)
128128

129-
reader := bufio.NewReader(os.Stdin)
130-
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)
131-
if err != nil {
132-
bootstrap.Error("ERROR: %v", err)
133-
workflowErr = err
134-
return types.ExitConfigError.Int()
129+
confirm := args.UpgradeAutoYes
130+
if confirm {
131+
logging.DebugStepBootstrap(bootstrap, "upgrade workflow", "auto-confirm enabled (--upgrade y)")
132+
} else {
133+
reader := bufio.NewReader(os.Stdin)
134+
var err error
135+
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)
136+
if err != nil {
137+
bootstrap.Error("ERROR: %v", err)
138+
workflowErr = err
139+
return types.ExitConfigError.Int()
140+
}
135141
}
136142
if !confirm {
137143
bootstrap.Println("Upgrade cancelled by user; no changes were made.")

cmd/proxsave/userns_context.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package main
2+
3+
import (
4+
"strings"
5+
6+
"github.com/tis24dev/proxsave/internal/environment"
7+
"github.com/tis24dev/proxsave/internal/logging"
8+
)
9+
10+
func logUserNamespaceContext(logger *logging.Logger, info environment.UnprivilegedContainerInfo) {
11+
mode := "unknown"
12+
note := ""
13+
shifted := (info.UIDMap.OK && info.UIDMap.Outside0 != 0) || (info.GIDMap.OK && info.GIDMap.Outside0 != 0)
14+
container := strings.TrimSpace(info.ContainerRuntime)
15+
inContainer := container != ""
16+
nonRoot := info.EUID != 0
17+
18+
switch {
19+
case shifted:
20+
mode = "unprivileged/rootless"
21+
note = " (some inventory may be skipped)"
22+
case nonRoot:
23+
mode = "non-root"
24+
note = " (some inventory may be skipped)"
25+
case inContainer:
26+
mode = "container"
27+
note = " (some inventory may be skipped)"
28+
case info.UIDMap.OK || info.GIDMap.OK:
29+
mode = "privileged"
30+
default:
31+
note = " (cannot infer; some inventory may be skipped)"
32+
}
33+
34+
containerHint := ""
35+
if container != "" {
36+
containerHint = " (runtime=" + container + ")"
37+
}
38+
39+
logging.Info("Privilege context: %s%s%s", mode, containerHint, note)
40+
41+
done := logging.DebugStart(logger, "privilege context detection", "")
42+
logging.DebugStep(logger, "privilege context detection", "uid_map: path=%s ok=%v outside0=%d length=%d read_err=%q parse_err=%q evidence=%q",
43+
info.UIDMap.SourcePath, info.UIDMap.OK, info.UIDMap.Outside0, info.UIDMap.Length, info.UIDMap.ReadError, info.UIDMap.ParseError, info.UIDMap.RawEvidence)
44+
logging.DebugStep(logger, "privilege context detection", "gid_map: path=%s ok=%v outside0=%d length=%d read_err=%q parse_err=%q evidence=%q",
45+
info.GIDMap.SourcePath, info.GIDMap.OK, info.GIDMap.Outside0, info.GIDMap.Length, info.GIDMap.ReadError, info.GIDMap.ParseError, info.GIDMap.RawEvidence)
46+
logging.DebugStep(logger, "privilege context detection", "systemd container: path=%s ok=%v value=%q read_err=%q",
47+
info.SystemdContainer.Source, info.SystemdContainer.OK, strings.TrimSpace(info.SystemdContainer.Value), info.SystemdContainer.ReadError)
48+
logging.DebugStep(logger, "privilege context detection", "env container: key=%q set=%v value=%q",
49+
info.EnvContainer.Key, info.EnvContainer.Set, strings.TrimSpace(info.EnvContainer.Value))
50+
logging.DebugStep(logger, "privilege context detection", "marker: path=%s ok=%v present=%v stat_err=%q",
51+
info.DockerMarker.Source, info.DockerMarker.OK, info.DockerMarker.Present, info.DockerMarker.StatError)
52+
logging.DebugStep(logger, "privilege context detection", "marker: path=%s ok=%v present=%v stat_err=%q",
53+
info.PodmanMarker.Source, info.PodmanMarker.OK, info.PodmanMarker.Present, info.PodmanMarker.StatError)
54+
logging.DebugStep(logger, "privilege context detection", "cgroup hint: path=%s ok=%v value=%q read_err=%q",
55+
info.Proc1CgroupHint.Source, info.Proc1CgroupHint.OK, strings.TrimSpace(info.Proc1CgroupHint.Value), info.Proc1CgroupHint.ReadError)
56+
logging.DebugStep(logger, "privilege context detection", "cgroup hint: path=%s ok=%v value=%q read_err=%q",
57+
info.SelfCgroupHint.Source, info.SelfCgroupHint.OK, strings.TrimSpace(info.SelfCgroupHint.Value), info.SelfCgroupHint.ReadError)
58+
logging.DebugStep(logger, "privilege context detection", "computed: detected=%v shifted=%v euid=%d container=%q container_src=%q details=%q",
59+
info.Detected, shifted, info.EUID, container, strings.TrimSpace(info.ContainerSource), info.Details)
60+
done(nil)
61+
}

docs/CLI_REFERENCE.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,11 @@ Some interactive commands support two interface modes:
142142
5. Optionally sets up notifications (Telegram, Email; Email defaults to `EMAIL_DELIVERY_METHOD=relay`)
143143
6. Optionally configures encryption (AGE setup)
144144
7. (TUI) Optionally selects a cron time (HH:MM) for the `proxsave` cron entry
145-
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`)
146-
9. Finalizes installation (symlinks, cron migration, permission checks)
145+
8. Optionally runs a post-install dry-run audit and offers to disable unused collectors (actionable hints like `set BACKUP_*=false to disable`)
146+
9. (If Telegram enabled) Shows Server ID and offers pairing verification (retry/skip supported)
147+
10. Finalizes installation (symlinks, cron migration, permission checks)
147148

148-
**Install log**: The installer writes a session log under `/tmp/proxsave/install-*.log` (includes post-install audit suggestions and any accepted disables).
149+
**Install log**: The installer writes a session log under `/tmp/proxsave/install-*.log` (includes audit results and Telegram pairing outcome).
149150

150151
### Configuration Upgrade
151152

@@ -173,6 +174,9 @@ Some interactive commands support two interface modes:
173174
# Upgrade binary to latest version
174175
./build/proxsave --upgrade
175176

177+
# Non-interactive upgrade (auto-confirm)
178+
./build/proxsave --upgrade y
179+
176180
# Full upgrade including configuration
177181
./build/proxsave --upgrade
178182
./build/proxsave --upgrade-config

docs/CONFIGURATION.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,19 @@ TELEGRAM_CHAT_ID= # Chat ID (your user ID or group ID)
791791
- **centralized**: Uses organization-wide bot (configured server-side)
792792
- **personal**: Uses your own bot (requires `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID`)
793793

794+
**Centralized mode pairing**:
795+
1. Enable Telegram (`TELEGRAM_ENABLED=true`, `BOT_TELEGRAM_TYPE=centralized`)
796+
2. Get your **Server ID**:
797+
- Shown during `--install` (TUI/CLI Telegram setup step)
798+
- Persisted in `<BASE_DIR>/identity/.server_identity` and reused on next runs
799+
- Also printed in the normal run logs
800+
3. Open Telegram and start `@ProxmoxAN_bot`
801+
4. Send the Server ID to the bot
802+
5. Verify pairing:
803+
- **TUI installer**: press `Check` (retry supported). `Continue` appears only after success; use `Skip` (or `ESC`) to proceed without verification.
804+
- **CLI installer**: opt into the check and retry when prompted.
805+
- Normal runs also verify automatically and will skip Telegram if not paired yet.
806+
794807
**Setup personal bot**:
795808
1. Message @BotFather on Telegram: `/newbot`
796809
2. Copy token to `TELEGRAM_BOT_TOKEN`

docs/INSTALL.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ ProxSave provides a built-in upgrade command to update your installation to the
8585
# Upgrade to latest version
8686
./build/proxsave --upgrade
8787

88+
# Non-interactive upgrade (auto-confirm)
89+
./build/proxsave --upgrade y
90+
8891
# Optionally update configuration template
8992
./build/proxsave --upgrade-config
9093

@@ -219,6 +222,36 @@ If the configuration file already exists, the **TUI wizard** will ask whether to
219222
6. **Encryption**: AGE encryption setup (runs sub-wizard immediately if enabled)
220223
7. **Cron schedule**: Choose cron time (HH:MM) for the `proxsave` cron entry (TUI mode only)
221224
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
225+
9. **Telegram pairing (optional)**: If Telegram (centralized) is enabled, shows your Server ID and lets you verify pairing with the bot (retry/skip supported)
226+
227+
#### Telegram pairing wizard (TUI)
228+
229+
If you enable Telegram notifications during `--install` (centralized bot), the installer opens an additional **Telegram Setup** screen after the post-install check.
230+
231+
It does **not** modify your `backup.env`. It only:
232+
- Computes/loads the **Server ID** and persists it (identity file)
233+
- Guides you through pairing with the centralized bot
234+
- Lets you verify pairing immediately (retry supported)
235+
236+
**What you see:**
237+
- **Instructions**: steps to start the bot and send the Server ID
238+
- **Server ID**: digits-only identifier + identity file path/persistence status
239+
- **Status**: live feedback from the pairing check
240+
- **Actions**:
241+
- `Check`: verify pairing (press again to retry)
242+
- `Continue`: available only after a successful check (centralized mode), or immediately in personal mode / when the Server ID is unavailable
243+
- `Skip`: leave without verification (in centralized mode, `ESC` behaves like Skip when not verified)
244+
245+
**Where the Server ID is stored:**
246+
- `<BASE_DIR>/identity/.server_identity`
247+
248+
**If `Check` fails:**
249+
- `403` / `409`: start the bot, send the Server ID, then try again
250+
- `422`: the Server ID looks invalid; re-run the installer or regenerate the identity file
251+
- Other errors: temporary server/network issue; retry or skip and pair later
252+
253+
**CLI mode:**
254+
- With `--install --cli`, the installer prints the Server ID and asks whether to run the check now (with a retry loop).
222255

223256
**Features:**
224257

@@ -227,7 +260,8 @@ If the configuration file already exists, the **TUI wizard** will ask whether to
227260
- Creates all necessary directories with proper permissions (0700)
228261
- Immediate AGE key generation if encryption is enabled
229262
- Optional post-install audit to disable unused collectors (keeps changes explicit; nothing is disabled silently)
230-
- Install session log under `/tmp/proxsave/install-*.log` (includes post-install audit suggestions and any accepted disables)
263+
- Optional Telegram pairing wizard (centralized mode) that displays Server ID and verifies the bot registration (retry/skip supported)
264+
- Install session log under `/tmp/proxsave/install-*.log` (includes audit results and Telegram pairing outcome)
231265

232266
After completion, edit `configs/backup.env` manually for advanced options.
233267

0 commit comments

Comments
 (0)