node add (and server add, mirror peer, patroni init) is usually given an address, while the ssh config names the machine: `Host gpu01` with `HostName 10.0.0.21` and the User, IdentityFile or ProxyJump it needs. That block did not match the address, so the address had to be added to its Host line by hand. Now the concrete Host names in the config and its Includes are read, ssh -G says each one's HostName, and the first that is the address (or resolves to it) is connected through. Under sudo, the caller's name is now a fallback: a User in the config's block for the machine comes first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
94 lines
3.0 KiB
Go
94 lines
3.0 KiB
Go
package onboard
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os/exec"
|
|
"runtime"
|
|
"sync"
|
|
)
|
|
|
|
// Remote runs a shell command on the machine being onboarded, with input.
|
|
type Remote interface {
|
|
Run(ctx context.Context, script string, stdin []byte) ([]byte, error)
|
|
}
|
|
|
|
// SSH is Remote through the system's ssh, so the operator's keys, agent and
|
|
// ~/.ssh/config apply exactly as they do when they log in by hand.
|
|
//
|
|
// Addr is looked up in the ssh config first: a Host block whose HostName is
|
|
// Addr is connected through by its name, so its User, IdentityFile and
|
|
// ProxyJump apply to a machine given by address, without the address
|
|
// having to be added to its Host line.
|
|
type SSH struct {
|
|
Addr, User string
|
|
// FallbackUser logs in when User is empty and the ssh config names no
|
|
// user for the machine: under sudo, the person who ran it.
|
|
FallbackUser string
|
|
|
|
once sync.Once
|
|
target string
|
|
}
|
|
|
|
// Target is what ssh is given: [user@]host, host being the ssh config's
|
|
// name for Addr when it has one.
|
|
func (s *SSH) Target(ctx context.Context) string {
|
|
s.once.Do(func() {
|
|
host := s.Addr
|
|
if a := configAlias(ctx, s.Addr); a != "" {
|
|
host = a
|
|
}
|
|
u := s.User
|
|
if u == "" && configUser(ctx, host) == "" {
|
|
u = s.FallbackUser
|
|
}
|
|
s.target = host
|
|
if u != "" {
|
|
s.target = u + "@" + host
|
|
}
|
|
})
|
|
return s.target
|
|
}
|
|
|
|
// sshOptions never prompt: a question nobody is there to answer would hang
|
|
// onboarding instead of failing it.
|
|
//
|
|
// accept-new rather than refusing unknown host keys: a machine provisioned a
|
|
// minute ago has a key nobody has seen, and the risk guarded against is a
|
|
// substituted machine, whose changed key is still refused.
|
|
var sshOptions = []string{
|
|
"-o", "BatchMode=yes",
|
|
"-o", "ConnectTimeout=10",
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", "ServerAliveInterval=15",
|
|
// "Permanently added … to the list of known hosts" and the like are
|
|
// for a person at a prompt; here they would be read as the answer.
|
|
"-o", "LogLevel=ERROR",
|
|
}
|
|
|
|
// Run runs script with sh on the machine. Files travel on standard input,
|
|
// not scp, which a hardened sshd may not offer.
|
|
func (s *SSH) Run(ctx context.Context, script string, stdin []byte) ([]byte, error) {
|
|
target := s.Target(ctx)
|
|
// "--" before the target: an address beginning with "-" is not read as
|
|
// an option.
|
|
args := append(append([]string{}, sshOptions...), "--", target, "sh", "-c", shellQuote(script))
|
|
cmd := exec.CommandContext(ctx, "ssh", args...)
|
|
if stdin != nil {
|
|
cmd.Stdin = bytes.NewReader(stdin)
|
|
}
|
|
// The answer is standard output alone: ssh and the machine's login
|
|
// scripts write to standard error, and mixed in they were taken for the
|
|
// answer — the first connection's "Warning: Permanently added" read as
|
|
// the machine's hostname. Standard error comes back only with a failure,
|
|
// where it is usually the reason.
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return append(stdout.Bytes(), stderr.Bytes()...), err
|
|
}
|
|
return stdout.Bytes(), nil
|
|
}
|
|
|
|
func hostArch() string { return runtime.GOARCH }
|