* ci: consolidate duplicate workflows and merge CodeQL configs Remove 17 duplicate workflow files that were split copies of the combined originals. Each family (CI, CodeQL, Coverage, PR Build, Alpha Release) had the same job duplicated across separate push/pull_request/schedule/manual trigger files. Merge codeql.yml and codescan.yml into a single codeql.yml with a language matrix covering go, javascript-typescript, python, and actions — matching the previous default setup coverage. Remaining workflows (one per family): - ci.yml (push + PR + manual) - codeql.yml (push + PR + schedule, all languages) - coverage.yml (push + PR + manual) - alpha-release.yml (push + manual) - pr-build.yml (PR + manual) - release.yml (tag push) - agent-verify.yml, auto-label.yml, auto-project.yml Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add collect, config, crypt, plugin packages and fix all lint issues Add four new infrastructure packages with CLI commands: - pkg/config: layered configuration (defaults → file → env → flags) - pkg/crypt: crypto primitives (Argon2id, AES-GCM, ChaCha20, HMAC, checksums) - pkg/plugin: plugin system with GitHub-based install/update/remove - pkg/collect: collection subsystem (GitHub, BitcoinTalk, market, papers, excavate) Fix all golangci-lint issues across the entire codebase (~100 errcheck, staticcheck SA1012/SA1019/ST1005, unused, ineffassign fixes) so that `core go qa` passes with 0 issues. Closes #167, #168, #170, #250, #251, #252, #253, #254, #255, #256 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
448 lines
11 KiB
Go
448 lines
11 KiB
Go
package ansible
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/host-uk/core/pkg/log"
|
|
"golang.org/x/crypto/ssh"
|
|
"golang.org/x/crypto/ssh/knownhosts"
|
|
)
|
|
|
|
// SSHClient handles SSH connections to remote hosts.
|
|
type SSHClient struct {
|
|
host string
|
|
port int
|
|
user string
|
|
password string
|
|
keyFile string
|
|
client *ssh.Client
|
|
mu sync.Mutex
|
|
become bool
|
|
becomeUser string
|
|
becomePass string
|
|
timeout time.Duration
|
|
insecure bool
|
|
}
|
|
|
|
// SSHConfig holds SSH connection configuration.
|
|
type SSHConfig struct {
|
|
Host string
|
|
Port int
|
|
User string
|
|
Password string
|
|
KeyFile string
|
|
Become bool
|
|
BecomeUser string
|
|
BecomePass string
|
|
Timeout time.Duration
|
|
Insecure bool
|
|
}
|
|
|
|
// NewSSHClient creates a new SSH client.
|
|
func NewSSHClient(cfg SSHConfig) (*SSHClient, error) {
|
|
if cfg.Port == 0 {
|
|
cfg.Port = 22
|
|
}
|
|
if cfg.User == "" {
|
|
cfg.User = "root"
|
|
}
|
|
if cfg.Timeout == 0 {
|
|
cfg.Timeout = 30 * time.Second
|
|
}
|
|
|
|
client := &SSHClient{
|
|
host: cfg.Host,
|
|
port: cfg.Port,
|
|
user: cfg.User,
|
|
password: cfg.Password,
|
|
keyFile: cfg.KeyFile,
|
|
become: cfg.Become,
|
|
becomeUser: cfg.BecomeUser,
|
|
becomePass: cfg.BecomePass,
|
|
timeout: cfg.Timeout,
|
|
insecure: cfg.Insecure,
|
|
}
|
|
|
|
return client, nil
|
|
}
|
|
|
|
// Connect establishes the SSH connection.
|
|
func (c *SSHClient) Connect(ctx context.Context) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.client != nil {
|
|
return nil
|
|
}
|
|
|
|
var authMethods []ssh.AuthMethod
|
|
|
|
// Try key-based auth first
|
|
if c.keyFile != "" {
|
|
keyPath := c.keyFile
|
|
if strings.HasPrefix(keyPath, "~") {
|
|
home, _ := os.UserHomeDir()
|
|
keyPath = filepath.Join(home, keyPath[1:])
|
|
}
|
|
|
|
if key, err := os.ReadFile(keyPath); err == nil {
|
|
if signer, err := ssh.ParsePrivateKey(key); err == nil {
|
|
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try default SSH keys
|
|
if len(authMethods) == 0 {
|
|
home, _ := os.UserHomeDir()
|
|
defaultKeys := []string{
|
|
filepath.Join(home, ".ssh", "id_ed25519"),
|
|
filepath.Join(home, ".ssh", "id_rsa"),
|
|
}
|
|
for _, keyPath := range defaultKeys {
|
|
if key, err := os.ReadFile(keyPath); err == nil {
|
|
if signer, err := ssh.ParsePrivateKey(key); err == nil {
|
|
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fall back to password auth
|
|
if c.password != "" {
|
|
authMethods = append(authMethods, ssh.Password(c.password))
|
|
authMethods = append(authMethods, ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) {
|
|
answers := make([]string, len(questions))
|
|
for i := range questions {
|
|
answers[i] = c.password
|
|
}
|
|
return answers, nil
|
|
}))
|
|
}
|
|
|
|
if len(authMethods) == 0 {
|
|
return log.E("ssh.Connect", "no authentication method available", nil)
|
|
}
|
|
|
|
// Host key verification
|
|
var hostKeyCallback ssh.HostKeyCallback
|
|
|
|
if c.insecure {
|
|
hostKeyCallback = ssh.InsecureIgnoreHostKey()
|
|
} else {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return log.E("ssh.Connect", "failed to get user home dir", err)
|
|
}
|
|
knownHostsPath := filepath.Join(home, ".ssh", "known_hosts")
|
|
|
|
cb, err := knownhosts.New(knownHostsPath)
|
|
if err != nil {
|
|
return log.E("ssh.Connect", "failed to load known_hosts (use Insecure=true to bypass)", err)
|
|
}
|
|
hostKeyCallback = cb
|
|
}
|
|
|
|
config := &ssh.ClientConfig{
|
|
User: c.user,
|
|
Auth: authMethods,
|
|
HostKeyCallback: hostKeyCallback,
|
|
Timeout: c.timeout,
|
|
}
|
|
|
|
addr := fmt.Sprintf("%s:%d", c.host, c.port)
|
|
|
|
// Connect with context timeout
|
|
var d net.Dialer
|
|
conn, err := d.DialContext(ctx, "tcp", addr)
|
|
if err != nil {
|
|
return log.E("ssh.Connect", fmt.Sprintf("dial %s", addr), err)
|
|
}
|
|
|
|
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
|
|
if err != nil {
|
|
// conn is closed by NewClientConn on error
|
|
return log.E("ssh.Connect", fmt.Sprintf("ssh connect %s", addr), err)
|
|
}
|
|
|
|
c.client = ssh.NewClient(sshConn, chans, reqs)
|
|
return nil
|
|
}
|
|
|
|
// Close closes the SSH connection.
|
|
func (c *SSHClient) Close() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.client != nil {
|
|
err := c.client.Close()
|
|
c.client = nil
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Run executes a command on the remote host.
|
|
func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) {
|
|
if err := c.Connect(ctx); err != nil {
|
|
return "", "", -1, err
|
|
}
|
|
|
|
session, err := c.client.NewSession()
|
|
if err != nil {
|
|
return "", "", -1, log.E("ssh.Run", "new session", err)
|
|
}
|
|
defer func() { _ = session.Close() }()
|
|
|
|
var stdoutBuf, stderrBuf bytes.Buffer
|
|
session.Stdout = &stdoutBuf
|
|
session.Stderr = &stderrBuf
|
|
|
|
// Apply become if needed
|
|
if c.become {
|
|
becomeUser := c.becomeUser
|
|
if becomeUser == "" {
|
|
becomeUser = "root"
|
|
}
|
|
// Escape single quotes in the command
|
|
escapedCmd := strings.ReplaceAll(cmd, "'", "'\\''")
|
|
if c.becomePass != "" {
|
|
// Use sudo with password via stdin (-S flag)
|
|
// We launch a goroutine to write the password to stdin
|
|
cmd = fmt.Sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
|
|
stdin, err := session.StdinPipe()
|
|
if err != nil {
|
|
return "", "", -1, log.E("ssh.Run", "stdin pipe", err)
|
|
}
|
|
go func() {
|
|
defer func() { _ = stdin.Close() }()
|
|
_, _ = io.WriteString(stdin, c.becomePass+"\n")
|
|
}()
|
|
} else if c.password != "" {
|
|
// Try using connection password for sudo
|
|
cmd = fmt.Sprintf("sudo -S -u %s bash -c '%s'", becomeUser, escapedCmd)
|
|
stdin, err := session.StdinPipe()
|
|
if err != nil {
|
|
return "", "", -1, log.E("ssh.Run", "stdin pipe", err)
|
|
}
|
|
go func() {
|
|
defer func() { _ = stdin.Close() }()
|
|
_, _ = io.WriteString(stdin, c.password+"\n")
|
|
}()
|
|
} else {
|
|
// Try passwordless sudo
|
|
cmd = fmt.Sprintf("sudo -n -u %s bash -c '%s'", becomeUser, escapedCmd)
|
|
}
|
|
}
|
|
|
|
// Run with context
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- session.Run(cmd)
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = session.Signal(ssh.SIGKILL)
|
|
return "", "", -1, ctx.Err()
|
|
case err := <-done:
|
|
exitCode = 0
|
|
if err != nil {
|
|
if exitErr, ok := err.(*ssh.ExitError); ok {
|
|
exitCode = exitErr.ExitStatus()
|
|
} else {
|
|
return stdoutBuf.String(), stderrBuf.String(), -1, err
|
|
}
|
|
}
|
|
return stdoutBuf.String(), stderrBuf.String(), exitCode, nil
|
|
}
|
|
}
|
|
|
|
// RunScript runs a script on the remote host.
|
|
func (c *SSHClient) RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error) {
|
|
// Escape the script for heredoc
|
|
cmd := fmt.Sprintf("bash <<'ANSIBLE_SCRIPT_EOF'\n%s\nANSIBLE_SCRIPT_EOF", script)
|
|
return c.Run(ctx, cmd)
|
|
}
|
|
|
|
// Upload copies a file to the remote host.
|
|
func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string, mode os.FileMode) error {
|
|
if err := c.Connect(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Read content
|
|
content, err := io.ReadAll(local)
|
|
if err != nil {
|
|
return log.E("ssh.Upload", "read content", err)
|
|
}
|
|
|
|
// Create parent directory
|
|
dir := filepath.Dir(remote)
|
|
dirCmd := fmt.Sprintf("mkdir -p %q", dir)
|
|
if c.become {
|
|
dirCmd = fmt.Sprintf("sudo mkdir -p %q", dir)
|
|
}
|
|
if _, _, _, err := c.Run(ctx, dirCmd); err != nil {
|
|
return log.E("ssh.Upload", "create parent dir", err)
|
|
}
|
|
|
|
// Use cat to write the file (simpler than SCP)
|
|
writeCmd := fmt.Sprintf("cat > %q && chmod %o %q", remote, mode, remote)
|
|
|
|
// If become is needed, we construct a command that reads password then content from stdin
|
|
// But we need to be careful with handling stdin for sudo + cat.
|
|
// We'll use a session with piped stdin.
|
|
|
|
session2, err := c.client.NewSession()
|
|
if err != nil {
|
|
return log.E("ssh.Upload", "new session for write", err)
|
|
}
|
|
defer func() { _ = session2.Close() }()
|
|
|
|
stdin, err := session2.StdinPipe()
|
|
if err != nil {
|
|
return log.E("ssh.Upload", "stdin pipe", err)
|
|
}
|
|
|
|
var stderrBuf bytes.Buffer
|
|
session2.Stderr = &stderrBuf
|
|
|
|
if c.become {
|
|
becomeUser := c.becomeUser
|
|
if becomeUser == "" {
|
|
becomeUser = "root"
|
|
}
|
|
|
|
pass := c.becomePass
|
|
if pass == "" {
|
|
pass = c.password
|
|
}
|
|
|
|
if pass != "" {
|
|
// Use sudo -S with password from stdin
|
|
writeCmd = fmt.Sprintf("sudo -S -u %s bash -c 'cat > %q && chmod %o %q'",
|
|
becomeUser, remote, mode, remote)
|
|
} else {
|
|
// Use passwordless sudo (sudo -n) to avoid consuming file content as password
|
|
writeCmd = fmt.Sprintf("sudo -n -u %s bash -c 'cat > %q && chmod %o %q'",
|
|
becomeUser, remote, mode, remote)
|
|
}
|
|
|
|
if err := session2.Start(writeCmd); err != nil {
|
|
return log.E("ssh.Upload", "start write", err)
|
|
}
|
|
|
|
go func() {
|
|
defer func() { _ = stdin.Close() }()
|
|
if pass != "" {
|
|
_, _ = io.WriteString(stdin, pass+"\n")
|
|
}
|
|
_, _ = stdin.Write(content)
|
|
}()
|
|
} else {
|
|
// Normal write
|
|
if err := session2.Start(writeCmd); err != nil {
|
|
return log.E("ssh.Upload", "start write", err)
|
|
}
|
|
|
|
go func() {
|
|
defer func() { _ = stdin.Close() }()
|
|
_, _ = stdin.Write(content)
|
|
}()
|
|
}
|
|
|
|
if err := session2.Wait(); err != nil {
|
|
return log.E("ssh.Upload", fmt.Sprintf("write failed (stderr: %s)", stderrBuf.String()), err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Download copies a file from the remote host.
|
|
func (c *SSHClient) Download(ctx context.Context, remote string) ([]byte, error) {
|
|
if err := c.Connect(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cmd := fmt.Sprintf("cat %q", remote)
|
|
|
|
stdout, stderr, exitCode, err := c.Run(ctx, cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if exitCode != 0 {
|
|
return nil, log.E("ssh.Download", fmt.Sprintf("cat failed: %s", stderr), nil)
|
|
}
|
|
|
|
return []byte(stdout), nil
|
|
}
|
|
|
|
// FileExists checks if a file exists on the remote host.
|
|
func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) {
|
|
cmd := fmt.Sprintf("test -e %q && echo yes || echo no", path)
|
|
stdout, _, exitCode, err := c.Run(ctx, cmd)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if exitCode != 0 {
|
|
// test command failed but didn't error - file doesn't exist
|
|
return false, nil
|
|
}
|
|
return strings.TrimSpace(stdout) == "yes", nil
|
|
}
|
|
|
|
// Stat returns file info from the remote host.
|
|
func (c *SSHClient) Stat(ctx context.Context, path string) (map[string]any, error) {
|
|
// Simple approach - get basic file info
|
|
cmd := fmt.Sprintf(`
|
|
if [ -e %q ]; then
|
|
if [ -d %q ]; then
|
|
echo "exists=true isdir=true"
|
|
else
|
|
echo "exists=true isdir=false"
|
|
fi
|
|
else
|
|
echo "exists=false"
|
|
fi
|
|
`, path, path)
|
|
|
|
stdout, _, _, err := c.Run(ctx, cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make(map[string]any)
|
|
parts := strings.Fields(strings.TrimSpace(stdout))
|
|
for _, part := range parts {
|
|
kv := strings.SplitN(part, "=", 2)
|
|
if len(kv) == 2 {
|
|
result[kv[0]] = kv[1] == "true"
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// SetBecome enables privilege escalation.
|
|
func (c *SSHClient) SetBecome(become bool, user, password string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.become = become
|
|
if user != "" {
|
|
c.becomeUser = user
|
|
}
|
|
if password != "" {
|
|
c.becomePass = password
|
|
}
|
|
}
|