Wire action-group variables, sudo, and command path resolution through the API and UI.
Completes run-if/set_variable and requires_sudo editing, sudoers path resolve helpers, and execute permission checks so node action groups can gate upgrades and elevate safely.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const MaxResolveCommandNames = 32
|
||||
|
||||
// ValidateCommandNames checks relative command basenames for resolve-commands.
|
||||
func ValidateCommandNames(names []string) ([]string, error) {
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("names is required")
|
||||
}
|
||||
if len(names) > MaxResolveCommandNames {
|
||||
return nil, fmt.Errorf("at most %d names allowed", MaxResolveCommandNames)
|
||||
}
|
||||
|
||||
cleaned := make([]string, 0, len(names))
|
||||
seen := make(map[string]struct{}, len(names))
|
||||
for _, raw := range names {
|
||||
name := strings.TrimSpace(raw)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("command name must not be empty")
|
||||
}
|
||||
if strings.Contains(name, "/") {
|
||||
return nil, fmt.Errorf("command name must be a basename without /")
|
||||
}
|
||||
if len(name) > 64 {
|
||||
return nil, fmt.Errorf("command name too long")
|
||||
}
|
||||
for _, runeValue := range name {
|
||||
if (runeValue >= 'a' && runeValue <= 'z') ||
|
||||
(runeValue >= 'A' && runeValue <= 'Z') ||
|
||||
(runeValue >= '0' && runeValue <= '9') ||
|
||||
runeValue == '.' || runeValue == '_' || runeValue == '-' || runeValue == '+' {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("command name contains invalid characters")
|
||||
}
|
||||
if _, exists := seen[name]; exists {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
cleaned = append(cleaned, name)
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
return nil, fmt.Errorf("names is required")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// BuildResolveCommandsRemote builds a shell snippet that prints name=path lines.
|
||||
func BuildResolveCommandsRemote(names []string) string {
|
||||
var builder strings.Builder
|
||||
for _, name := range names {
|
||||
quoted := shellQuote(name)
|
||||
builder.WriteString("path=$(command -v -- ")
|
||||
builder.WriteString(quoted)
|
||||
builder.WriteString(" 2>/dev/null || true); printf '%s=%s\\n' ")
|
||||
builder.WriteString(quoted)
|
||||
builder.WriteString(" \"$path\"; ")
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// ParseResolveCommandsOutput parses name=path lines into found paths and missing names.
|
||||
func ParseResolveCommandsOutput(names []string, stdout string) (paths map[string]string, missing []string) {
|
||||
paths = make(map[string]string, len(names))
|
||||
wanted := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
wanted[name] = struct{}{}
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(stdout, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
name, path, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
path = strings.TrimSpace(path)
|
||||
if _, ok := wanted[name]; !ok {
|
||||
continue
|
||||
}
|
||||
if path == "" || !strings.HasPrefix(path, "/") {
|
||||
continue
|
||||
}
|
||||
paths[name] = path
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
if _, ok := paths[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
return paths, missing
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateCommandNames(t *testing.T) {
|
||||
cleaned, err := ValidateCommandNames([]string{" apt ", "systemctl", "apt"})
|
||||
if err != nil {
|
||||
t.Fatalf("valid names: %v", err)
|
||||
}
|
||||
if len(cleaned) != 2 || cleaned[0] != "apt" || cleaned[1] != "systemctl" {
|
||||
t.Fatalf("cleaned = %#v", cleaned)
|
||||
}
|
||||
|
||||
if _, err := ValidateCommandNames(nil); err == nil {
|
||||
t.Fatal("expected error for empty names")
|
||||
}
|
||||
if _, err := ValidateCommandNames([]string{"/usr/bin/apt"}); err == nil {
|
||||
t.Fatal("expected error for path")
|
||||
}
|
||||
if _, err := ValidateCommandNames([]string{"apt;rm"}); err == nil {
|
||||
t.Fatal("expected error for metacharacters")
|
||||
}
|
||||
|
||||
tooMany := make([]string, MaxResolveCommandNames+1)
|
||||
for index := range tooMany {
|
||||
tooMany[index] = fmt.Sprintf("cmd%d", index)
|
||||
}
|
||||
if _, err := ValidateCommandNames(tooMany); err == nil {
|
||||
t.Fatal("expected error for too many names")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolveCommandsOutput(t *testing.T) {
|
||||
paths, missing := ParseResolveCommandsOutput(
|
||||
[]string{"apt", "missing", "systemctl"},
|
||||
"apt=/usr/bin/apt\nmissing=\nsystemctl=/usr/bin/systemctl\n",
|
||||
)
|
||||
if paths["apt"] != "/usr/bin/apt" || paths["systemctl"] != "/usr/bin/systemctl" {
|
||||
t.Fatalf("paths = %#v", paths)
|
||||
}
|
||||
if len(missing) != 1 || missing[0] != "missing" {
|
||||
t.Fatalf("missing = %#v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResolveCommandsRemote(t *testing.T) {
|
||||
remote := BuildResolveCommandsRemote([]string{"apt"})
|
||||
if !strings.Contains(remote, "command -v -- 'apt'") {
|
||||
t.Fatalf("remote = %q", remote)
|
||||
}
|
||||
if !strings.Contains(remote, "printf '%s=%s\\n' 'apt'") {
|
||||
t.Fatalf("remote missing printf = %q", remote)
|
||||
}
|
||||
}
|
||||
@@ -32,11 +32,12 @@ func RunSSHShell(
|
||||
if strings.TrimSpace(command) == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("command is required")
|
||||
}
|
||||
fullCommand := prependEnvExports(env, command)
|
||||
fullCommand := PrependEnvExports(env, command)
|
||||
return runSSHSession(hostIP, username, privateKeyPEM, passphrase, fullCommand, timeout)
|
||||
}
|
||||
|
||||
// RunSSHScript uploads and runs a multi-line script via bash -s on the remote host.
|
||||
// When requiresSudo is true, the remote command is "sudo -n bash -s".
|
||||
func RunSSHScript(
|
||||
hostIP string,
|
||||
username string,
|
||||
@@ -45,6 +46,7 @@ func RunSSHScript(
|
||||
scriptBody string,
|
||||
env map[string]string,
|
||||
timeout time.Duration,
|
||||
requiresSudo bool,
|
||||
) (SSHRunResult, error) {
|
||||
if strings.TrimSpace(scriptBody) == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("script body is required")
|
||||
@@ -95,7 +97,11 @@ func RunSSHScript(
|
||||
session.Stderr = &stderrBuf
|
||||
session.Stdin = strings.NewReader(scriptBody)
|
||||
|
||||
remoteCommand := prependEnvExports(env, "bash -s")
|
||||
bashCommand := "bash -s"
|
||||
if requiresSudo {
|
||||
bashCommand = "sudo -n bash -s"
|
||||
}
|
||||
remoteCommand := PrependEnvExports(env, bashCommand)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- session.Run(remoteCommand)
|
||||
@@ -207,7 +213,8 @@ func finishSSHResult(stdout string, stderr string, err error) (SSHRunResult, err
|
||||
return result, err
|
||||
}
|
||||
|
||||
func prependEnvExports(env map[string]string, command string) string {
|
||||
// PrependEnvExports builds the remote command string with optional export PREFIX; command.
|
||||
func PrependEnvExports(env map[string]string, command string) string {
|
||||
if len(env) == 0 {
|
||||
return command
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user