125 lines
4.4 KiB
Go
125 lines
4.4 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"sysagent/internal/policy"
|
|
)
|
|
|
|
// transientRule describe un error transitorio que justifica reintentar (§54).
|
|
//
|
|
// Solo se incluyen errores que garantizan que el comando NO llegó a hacer
|
|
// su trabajo (no obtuvo el bloqueo, no resolvió el nombre), para que
|
|
// reintentar sea seguro incluso con comandos que modifican el sistema.
|
|
type transientRule struct {
|
|
re *regexp.Regexp
|
|
what string
|
|
backoff []time.Duration // una espera por reintento
|
|
}
|
|
|
|
var transientRules = []transientRule{
|
|
{
|
|
re: regexp.MustCompile(`(?i)could not get lock /var/lib/(dpkg|apt)|unable to acquire the dpkg frontend lock|unable to lock (the )?(administration|download) directory|waiting for cache lock|is another process using it\?`),
|
|
what: "package manager lock held by another process",
|
|
backoff: []time.Duration{5 * time.Second, 15 * time.Second, 30 * time.Second},
|
|
},
|
|
{
|
|
re: regexp.MustCompile(`(?i)temporary failure in name resolution|temporary failure resolving|could not resolve host|name or service not known`),
|
|
what: "temporary DNS failure",
|
|
backoff: []time.Duration{2 * time.Second, 5 * time.Second},
|
|
},
|
|
}
|
|
|
|
// retryScale permite acortar las esperas en tests.
|
|
var retryScale = time.Duration(1)
|
|
|
|
// runWithRetry ejecuta spec y reintenta con backoff ante errores
|
|
// transitorios conocidos. Nunca reintenta indefinidamente: cada regla tiene
|
|
// un número máximo de reintentos. Añade también la pista de escalada de
|
|
// privilegios cuando falla por permisos (§46).
|
|
func runWithRetry(ctx context.Context, env *Env, spec CommandSpec, display, cwd string, risks []policy.Risk) Result {
|
|
var notes []string
|
|
r := RunCommand(ctx, spec)
|
|
attempts := map[int]int{}
|
|
for {
|
|
if r.ExitCode == 0 || r.TimedOut || ctx.Err() != nil {
|
|
break
|
|
}
|
|
rule := -1
|
|
out := r.Stderr + "\n" + r.Stdout
|
|
for i, tr := range transientRules {
|
|
if tr.re.MatchString(out) {
|
|
rule = i
|
|
break
|
|
}
|
|
}
|
|
if rule < 0 || attempts[rule] >= len(transientRules[rule].backoff) {
|
|
break
|
|
}
|
|
base := transientRules[rule].backoff[attempts[rule]]
|
|
wait := base * retryScale
|
|
attempts[rule]++
|
|
notes = append(notes, fmt.Sprintf("retry %d/%d after %s: %s", attempts[rule], len(transientRules[rule].backoff), base, transientRules[rule].what))
|
|
env.Log.Infof("%s: %s; retrying in %s", display, transientRules[rule].what, base)
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-time.After(wait):
|
|
}
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
r = RunCommand(ctx, spec)
|
|
}
|
|
|
|
res := commandResult(display, cwd, r)
|
|
if len(notes) > 0 {
|
|
res.Output += "\n--- retries ---\n" + strings.Join(notes, "\n")
|
|
res.Audit = append(res.Audit, "retries", fmt.Sprint(len(notes)))
|
|
}
|
|
if r.ExitCode != 0 && permissionDeniedRe.MatchString(r.Stderr+r.Stdout) {
|
|
res.Output += "\n" + escalationHint()
|
|
}
|
|
return res
|
|
}
|
|
|
|
var permissionDeniedRe = regexp.MustCompile(`(?i)permission denied|operation not permitted|must be (run as )?root|are you root|requires (superuser|root) privileges|superuser privileges required|only root can`)
|
|
|
|
var (
|
|
escalationOnce sync.Once
|
|
escalationText string
|
|
)
|
|
|
|
// escalationHint explica al modelo si puede elevar privilegios (§46): no se
|
|
// asume root porque el proyecto lo pida; se comprueba qué hay disponible y
|
|
// si funciona sin contraseña (sudo -n / doas -n no preguntan nunca).
|
|
func escalationHint() string {
|
|
if os.Geteuid() == 0 {
|
|
return "hint: the agent already runs as root (euid=0); the denial comes from something else (read-only filesystem, SELinux/AppArmor, immutable attribute, capabilities or a container restriction)."
|
|
}
|
|
escalationOnce.Do(func() {
|
|
var parts []string
|
|
for _, tool := range []string{"sudo", "doas"} {
|
|
if _, err := execLookPath(tool); err != nil {
|
|
continue
|
|
}
|
|
r := RunCommand(context.Background(), CommandSpec{Name: tool, Args: []string{"-n", "true"}, Timeout: 5 * time.Second})
|
|
parts = append(parts, fmt.Sprintf("%s (passwordless: %t)", tool, r.ExitCode == 0))
|
|
}
|
|
if _, err := execLookPath("su"); err == nil {
|
|
parts = append(parts, "su (needs the target user's password)")
|
|
}
|
|
avail := "none available"
|
|
if len(parts) > 0 {
|
|
avail = strings.Join(parts, ", ")
|
|
}
|
|
escalationText = fmt.Sprintf("hint: permission denied and the agent is not running as root (uid=%d euid=%d). Privilege escalation: %s. Use it only if the policy and the task justify it; if a password is required, ask the operator with ask_secret (CLI only).", os.Getuid(), os.Geteuid(), avail)
|
|
})
|
|
return escalationText
|
|
}
|