Add per-node action groups with schedules, runs, and logs.

Let operators manage ordered action groups on each host, run them manually or on a schedule over SSH, and inspect disk-backed JSON run logs from a node detail UI.
This commit is contained in:
2026-07-19 20:01:11 +02:00
parent 50568127c5
commit 32af91fd30
22 changed files with 4337 additions and 32 deletions
+228
View File
@@ -0,0 +1,228 @@
package auth
import (
"bytes"
"fmt"
"net"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
const defaultSSHRunTimeout = 5 * time.Minute
// SSHRunResult captures stdout, stderr, and exit status from a remote command.
type SSHRunResult struct {
Stdout string
Stderr string
ExitCode int
}
// RunSSHShell runs a single shell command on the remote host over SSH.
func RunSSHShell(
hostIP string,
username string,
privateKeyPEM string,
passphrase string,
command string,
env map[string]string,
timeout time.Duration,
) (SSHRunResult, error) {
if strings.TrimSpace(command) == "" {
return SSHRunResult{}, fmt.Errorf("command is required")
}
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.
func RunSSHScript(
hostIP string,
username string,
privateKeyPEM string,
passphrase string,
scriptBody string,
env map[string]string,
timeout time.Duration,
) (SSHRunResult, error) {
if strings.TrimSpace(scriptBody) == "" {
return SSHRunResult{}, fmt.Errorf("script body is required")
}
hostIP = strings.TrimSpace(hostIP)
username = strings.TrimSpace(username)
if hostIP == "" {
return SSHRunResult{}, fmt.Errorf("host IP is required")
}
if username == "" {
return SSHRunResult{}, fmt.Errorf("username is required")
}
if timeout <= 0 {
timeout = defaultSSHRunTimeout
}
signer, err := parseSigner(privateKeyPEM, passphrase)
if err != nil {
return SSHRunResult{}, err
}
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: sshDialTimeout,
}
address := net.JoinHostPort(hostIP, defaultSSHPort)
client, err := ssh.Dial("tcp", address, config)
if err != nil {
return SSHRunResult{}, fmt.Errorf("ssh dial %s: %w", address, err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return SSHRunResult{}, fmt.Errorf("ssh session: %w", err)
}
defer session.Close()
var stdoutBuf bytes.Buffer
var stderrBuf bytes.Buffer
session.Stdout = &stdoutBuf
session.Stderr = &stderrBuf
session.Stdin = strings.NewReader(scriptBody)
remoteCommand := prependEnvExports(env, "bash -s")
done := make(chan error, 1)
go func() {
done <- session.Run(remoteCommand)
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-done:
return finishSSHResult(stdoutBuf.String(), stderrBuf.String(), err)
case <-timer.C:
_ = session.Close()
return SSHRunResult{
Stdout: stdoutBuf.String(),
Stderr: stderrBuf.String(),
ExitCode: -1,
}, fmt.Errorf("ssh command timed out after %s", timeout)
}
}
func runSSHSession(
hostIP string,
username string,
privateKeyPEM string,
passphrase string,
command string,
timeout time.Duration,
) (SSHRunResult, error) {
hostIP = strings.TrimSpace(hostIP)
username = strings.TrimSpace(username)
if hostIP == "" {
return SSHRunResult{}, fmt.Errorf("host IP is required")
}
if username == "" {
return SSHRunResult{}, fmt.Errorf("username is required")
}
if timeout <= 0 {
timeout = defaultSSHRunTimeout
}
signer, err := parseSigner(privateKeyPEM, passphrase)
if err != nil {
return SSHRunResult{}, err
}
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: sshDialTimeout,
}
address := net.JoinHostPort(hostIP, defaultSSHPort)
client, err := ssh.Dial("tcp", address, config)
if err != nil {
return SSHRunResult{}, fmt.Errorf("ssh dial %s: %w", address, err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return SSHRunResult{}, fmt.Errorf("ssh session: %w", err)
}
defer session.Close()
var stdoutBuf bytes.Buffer
var stderrBuf bytes.Buffer
session.Stdout = &stdoutBuf
session.Stderr = &stderrBuf
done := make(chan error, 1)
go func() {
done <- session.Run(command)
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-done:
return finishSSHResult(stdoutBuf.String(), stderrBuf.String(), err)
case <-timer.C:
_ = session.Close()
return SSHRunResult{
Stdout: stdoutBuf.String(),
Stderr: stderrBuf.String(),
ExitCode: -1,
}, fmt.Errorf("ssh command timed out after %s", timeout)
}
}
func finishSSHResult(stdout string, stderr string, err error) (SSHRunResult, error) {
result := SSHRunResult{
Stdout: stdout,
Stderr: stderr,
ExitCode: 0,
}
if err == nil {
return result, nil
}
if exitErr, ok := err.(*ssh.ExitError); ok {
result.ExitCode = exitErr.ExitStatus()
return result, nil
}
result.ExitCode = -1
return result, err
}
func prependEnvExports(env map[string]string, command string) string {
if len(env) == 0 {
return command
}
var builder strings.Builder
for name, value := range env {
builder.WriteString("export ")
builder.WriteString(shellQuote(name))
builder.WriteString("=")
builder.WriteString(shellQuote(value))
builder.WriteString("; ")
}
builder.WriteString(command)
return builder.String()
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
}