feat(ssh): honor per-host IdentityAgent settings (#2992)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -19,11 +20,13 @@ import (
|
||||
|
||||
const sshDialTimeout = 30 * time.Second
|
||||
|
||||
var sshSystemConfigPath = filepath.Join(string(filepath.Separator), "etc", "ssh", "ssh_config")
|
||||
|
||||
// SSHIdentitySource enumerates how kandev obtains an SSH credential.
|
||||
type SSHIdentitySource string
|
||||
|
||||
const (
|
||||
SSHIdentitySourceAgent SSHIdentitySource = "agent" // use SSH_AUTH_SOCK
|
||||
SSHIdentitySourceAgent SSHIdentitySource = "agent" // use IdentityAgent or SSH_AUTH_SOCK
|
||||
SSHIdentitySourceFile SSHIdentitySource = "file" // explicit IdentityFile path
|
||||
)
|
||||
|
||||
@@ -34,7 +37,9 @@ type SSHTarget struct {
|
||||
User string // resolved User
|
||||
IdentitySource SSHIdentitySource // agent | file
|
||||
IdentityFile string // path when IdentitySource=file
|
||||
IdentityAgent string // resolved agent socket override from ssh_config
|
||||
ProxyJump string // optional bastion (single hop)
|
||||
OriginalHost string // original alias/host, for OpenSSH %n/%k expansion
|
||||
|
||||
// PinnedFingerprint is the SHA256 fingerprint the user trusted in settings.
|
||||
// Empty means "first connect / test mode" — accept any key and return what we saw.
|
||||
@@ -67,7 +72,9 @@ type SSHConnConfig struct {
|
||||
func ResolveSSHTarget(cfg SSHConnConfig) (*SSHTarget, error) {
|
||||
t := initialTargetFromConfig(cfg)
|
||||
if alias := strings.TrimSpace(cfg.HostAlias); alias != "" {
|
||||
inheritFromSSHConfig(alias, t)
|
||||
if err := inheritFromSSHConfig(alias, t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := applyTargetDefaults(t); err != nil {
|
||||
return nil, err
|
||||
@@ -79,6 +86,10 @@ func ResolveSSHTarget(cfg SSHConnConfig) (*SSHTarget, error) {
|
||||
// initialTargetFromConfig copies form values into a partially populated target.
|
||||
// All fields are trimmed; SSH-config inheritance and final defaults are applied later.
|
||||
func initialTargetFromConfig(cfg SSHConnConfig) *SSHTarget {
|
||||
originalHost := strings.TrimSpace(cfg.HostAlias)
|
||||
if originalHost == "" {
|
||||
originalHost = strings.TrimSpace(cfg.Host)
|
||||
}
|
||||
return &SSHTarget{
|
||||
Host: strings.TrimSpace(cfg.Host),
|
||||
Port: cfg.Port,
|
||||
@@ -86,6 +97,7 @@ func initialTargetFromConfig(cfg SSHConnConfig) *SSHTarget {
|
||||
IdentitySource: cfg.IdentitySource,
|
||||
IdentityFile: strings.TrimSpace(cfg.IdentityFile),
|
||||
ProxyJump: strings.TrimSpace(cfg.ProxyJump),
|
||||
OriginalHost: originalHost,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +108,8 @@ func initialTargetFromConfig(cfg SSHConnConfig) *SSHTarget {
|
||||
// package's package-level Get/GetStrict use a sync.Once to load the config once
|
||||
// per process, which breaks tests (and any user who edits their config) — each
|
||||
// resolve should see the current on-disk state.
|
||||
func inheritFromSSHConfig(alias string, t *SSHTarget) {
|
||||
cfg := loadUserSSHConfig()
|
||||
func inheritFromSSHConfig(alias string, t *SSHTarget) error {
|
||||
cfg := loadEffectiveSSHConfig()
|
||||
if t.Host == "" {
|
||||
if v := lookupSSHConfig(cfg, alias, "HostName"); v != "" {
|
||||
t.Host = strings.TrimSpace(v)
|
||||
@@ -129,17 +141,35 @@ func inheritFromSSHConfig(alias string, t *SSHTarget) {
|
||||
t.ProxyJump = v
|
||||
}
|
||||
}
|
||||
// IdentityAgent refines agent selection and therefore applies even when
|
||||
// the create form explicitly selected "agent". An explicit file source
|
||||
// remains authoritative.
|
||||
if t.IdentitySource != SSHIdentitySourceFile {
|
||||
t.IdentityAgent = strings.TrimSpace(lookupSSHConfig(cfg, alias, "IdentityAgent"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadUserSSHConfig parses $HOME/.ssh/config on demand. Returns nil if the
|
||||
// file is absent or unreadable; lookupSSHConfig handles a nil config by
|
||||
// returning the empty string.
|
||||
func loadUserSSHConfig() *ssh_config.Config {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
type effectiveSSHConfig struct {
|
||||
user *ssh_config.Config
|
||||
system *ssh_config.Config
|
||||
}
|
||||
|
||||
// loadEffectiveSSHConfig parses both user and system config on every resolve.
|
||||
// User values win, matching OpenSSH's first-obtained-value behavior.
|
||||
func loadEffectiveSSHConfig() effectiveSSHConfig {
|
||||
home, _ := os.UserHomeDir()
|
||||
return effectiveSSHConfig{
|
||||
user: loadSSHConfigFile(filepath.Join(home, ".ssh", "config")),
|
||||
system: loadSSHConfigFile(sshSystemConfigPath),
|
||||
}
|
||||
}
|
||||
|
||||
func loadSSHConfigFile(path string) *ssh_config.Config {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(filepath.Join(home, ".ssh", "config"))
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -151,20 +181,22 @@ func loadUserSSHConfig() *ssh_config.Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func lookupSSHConfig(cfg *ssh_config.Config, alias, key string) string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
func lookupSSHConfig(cfg effectiveSSHConfig, alias, key string) string {
|
||||
for _, candidate := range []*ssh_config.Config{cfg.user, cfg.system} {
|
||||
if candidate == nil {
|
||||
continue
|
||||
}
|
||||
v, err := candidate.Get(alias, key)
|
||||
if err == nil && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
v, err := cfg.Get(alias, key)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
return ""
|
||||
}
|
||||
|
||||
// applyTargetDefaults fills in port/user/identity defaults and validates that
|
||||
// host is set. Identity defaults: ssh-agent if SSH_AUTH_SOCK is set, otherwise
|
||||
// fall back to ~/.ssh/id_ed25519.
|
||||
// host is set. Identity defaults: ssh-agent if IdentityAgent or SSH_AUTH_SOCK
|
||||
// selects a socket, otherwise fall back to ~/.ssh/id_ed25519.
|
||||
func applyTargetDefaults(t *SSHTarget) error {
|
||||
if t.Host == "" {
|
||||
return errors.New("ssh: host is required")
|
||||
@@ -180,7 +212,7 @@ func applyTargetDefaults(t *SSHTarget) error {
|
||||
t.User = current
|
||||
}
|
||||
if t.IdentitySource == "" {
|
||||
if os.Getenv("SSH_AUTH_SOCK") != "" {
|
||||
if t.IdentityAgent != "" || os.Getenv("SSH_AUTH_SOCK") != "" {
|
||||
t.IdentitySource = SSHIdentitySourceAgent
|
||||
} else {
|
||||
t.IdentitySource = SSHIdentitySourceFile
|
||||
@@ -189,6 +221,12 @@ func applyTargetDefaults(t *SSHTarget) error {
|
||||
}
|
||||
if t.IdentitySource == SSHIdentitySourceFile {
|
||||
t.IdentityFile = expandHome(t.IdentityFile)
|
||||
} else if t.IdentityAgent != "" && !strings.EqualFold(t.IdentityAgent, "none") {
|
||||
expanded, err := expandIdentityAgent(t.IdentityAgent, t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh: expand IdentityAgent %q: %w", t.IdentityAgent, err)
|
||||
}
|
||||
t.IdentityAgent = expanded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -208,6 +246,109 @@ func expandHome(p string) string {
|
||||
return filepath.Join(home, p[2:])
|
||||
}
|
||||
|
||||
// expandIdentityAgent implements OpenSSH IdentityAgent tilde, percent-token,
|
||||
// ${VAR}, and legacy whole-value $VAR expansion.
|
||||
func expandIdentityAgent(value string, t *SSHTarget) (string, error) {
|
||||
tokens, err := identityAgentTokens(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
value, err = expandIdentityAgentPercentTokens(expandHome(value), tokens)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
value, err = expandIdentityAgentEnvironment(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.HasPrefix(value, "$") {
|
||||
name := value[1:]
|
||||
if name == "" || strings.ContainsAny(name, "/{}") {
|
||||
return "", fmt.Errorf("invalid legacy environment variable %q", value)
|
||||
}
|
||||
socket, ok := os.LookupEnv(name)
|
||||
if !ok || socket == "" {
|
||||
return "", fmt.Errorf("environment variable %s is not set", name)
|
||||
}
|
||||
return socket, nil
|
||||
}
|
||||
if value == "SSH_AUTH_SOCK" {
|
||||
return os.Getenv("SSH_AUTH_SOCK"), nil
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func identityAgentTokens(t *SSHTarget) (map[byte]string, error) {
|
||||
localUser, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("local user: %w", err)
|
||||
}
|
||||
fullHost, err := os.Hostname()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("local hostname: %w", err)
|
||||
}
|
||||
original := t.OriginalHost
|
||||
if original == "" {
|
||||
original = t.Host
|
||||
}
|
||||
return map[byte]string{
|
||||
'%': "%", 'd': localUser.HomeDir, 'h': t.Host, 'i': localUser.Uid,
|
||||
'j': t.ProxyJump, 'k': original, 'L': strings.SplitN(fullHost, ".", 2)[0], 'l': fullHost,
|
||||
'n': original, 'p': strconv.Itoa(t.Port), 'r': t.User, 'u': localUser.Username,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func expandIdentityAgentPercentTokens(value string, tokens map[byte]string) (string, error) {
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] != '%' {
|
||||
out.WriteByte(value[i])
|
||||
continue
|
||||
}
|
||||
if i+1 == len(value) {
|
||||
return "", errors.New("trailing % token")
|
||||
}
|
||||
i++
|
||||
replacement, ok := tokens[value[i]]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unsupported token %%%c", value[i])
|
||||
}
|
||||
out.WriteString(replacement)
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
func expandIdentityAgentEnvironment(value string) (string, error) {
|
||||
// Scan the original value only. Environment contents can contain `${...}`
|
||||
// text, but that text is replacement data, not another expression to expand.
|
||||
var out strings.Builder
|
||||
for cursor := 0; cursor < len(value); {
|
||||
relativeStart := strings.Index(value[cursor:], "${")
|
||||
if relativeStart == -1 {
|
||||
out.WriteString(value[cursor:])
|
||||
break
|
||||
}
|
||||
start := cursor + relativeStart
|
||||
out.WriteString(value[cursor:start])
|
||||
endRel := strings.IndexByte(value[start+2:], '}')
|
||||
if endRel == -1 {
|
||||
return "", errors.New("unterminated environment variable")
|
||||
}
|
||||
end := start + 2 + endRel
|
||||
name := value[start+2 : end]
|
||||
if name == "" {
|
||||
return "", errors.New("empty environment variable name")
|
||||
}
|
||||
env, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("environment variable %s is not set", name)
|
||||
}
|
||||
out.WriteString(env)
|
||||
cursor = end + 1
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
// SSHFingerprint returns the SHA256 fingerprint of a host key in the
|
||||
// standard `SHA256:<base64>` format used by OpenSSH and the `ssh` CLI.
|
||||
func SSHFingerprint(key ssh.PublicKey) string {
|
||||
@@ -237,13 +378,19 @@ func buildAuthMethods(target *SSHTarget) ([]ssh.AuthMethod, func(), error) {
|
||||
noop := func() {}
|
||||
switch target.IdentitySource {
|
||||
case SSHIdentitySourceAgent:
|
||||
sock := os.Getenv("SSH_AUTH_SOCK")
|
||||
if strings.EqualFold(target.IdentityAgent, "none") {
|
||||
return nil, noop, errors.New("ssh-agent identity source selected but IdentityAgent is set to none")
|
||||
}
|
||||
sock := target.IdentityAgent
|
||||
if sock == "" {
|
||||
return nil, noop, errors.New("ssh-agent identity source selected but SSH_AUTH_SOCK is not set; start an agent and add your key (ssh-add)")
|
||||
sock = os.Getenv("SSH_AUTH_SOCK")
|
||||
}
|
||||
if sock == "" {
|
||||
return nil, noop, errors.New("ssh-agent identity source selected but SSH_AUTH_SOCK is not set and IdentityAgent did not resolve to a socket; start an agent and add your key (ssh-add)")
|
||||
}
|
||||
conn, err := net.Dial("unix", sock)
|
||||
if err != nil {
|
||||
return nil, noop, fmt.Errorf("failed to connect to ssh-agent: %w", err)
|
||||
return nil, noop, fmt.Errorf("failed to connect to ssh-agent at %s: %w", sock, err)
|
||||
}
|
||||
ag := agent.NewClient(conn)
|
||||
cleanup := func() { _ = conn.Close() }
|
||||
@@ -455,7 +602,7 @@ func tofuLogOnlyCallback(proxyJump, _ string) ssh.HostKeyCallback {
|
||||
// resolveBastion turns the target's ProxyJump value into a connection
|
||||
// SSHTarget. Two accepted shapes:
|
||||
//
|
||||
// - alias: looked up in ~/.ssh/config (HostName / Port / User / IdentityFile)
|
||||
// - alias: resolved from effective OpenSSH config (HostName / Port / User / IdentityAgent / IdentityFile)
|
||||
// - literal "[user@]host[:port]": parsed inline, no config lookup
|
||||
//
|
||||
// The literal form has no Host block to inherit from, so passing it straight
|
||||
@@ -464,19 +611,34 @@ func tofuLogOnlyCallback(proxyJump, _ string) ssh.HostKeyCallback {
|
||||
// Identity defaults flow from the target so the same key reaches the bastion.
|
||||
func resolveBastion(target *SSHTarget) (*SSHTarget, error) {
|
||||
if user, host, port, ok := parseLiteralProxyJump(target.ProxyJump); ok {
|
||||
return ResolveSSHTarget(SSHConnConfig{
|
||||
bastion, err := ResolveSSHTarget(SSHConnConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
User: user,
|
||||
IdentitySource: target.IdentitySource,
|
||||
IdentityFile: target.IdentityFile,
|
||||
})
|
||||
if err == nil && bastion.IdentitySource == SSHIdentitySourceAgent {
|
||||
bastion.IdentityAgent = target.IdentityAgent
|
||||
}
|
||||
return bastion, err
|
||||
}
|
||||
return ResolveSSHTarget(SSHConnConfig{
|
||||
HostAlias: target.ProxyJump,
|
||||
IdentitySource: target.IdentitySource,
|
||||
IdentityFile: target.IdentityFile,
|
||||
})
|
||||
|
||||
// A jump alias owns its explicitly configured auth. If it has no auth
|
||||
// directive, retain Kandev's existing target-auth fallback behavior.
|
||||
effective := loadEffectiveSSHConfig()
|
||||
hasJumpIdentity := lookupSSHConfig(effective, target.ProxyJump, "IdentityFile") != "" ||
|
||||
lookupSSHConfig(effective, target.ProxyJump, "IdentityAgent") != ""
|
||||
cfg := SSHConnConfig{HostAlias: target.ProxyJump}
|
||||
if !hasJumpIdentity {
|
||||
cfg.IdentitySource = target.IdentitySource
|
||||
cfg.IdentityFile = target.IdentityFile
|
||||
}
|
||||
bastion, err := ResolveSSHTarget(cfg)
|
||||
if err == nil && !hasJumpIdentity && bastion.IdentitySource == SSHIdentitySourceAgent {
|
||||
bastion.IdentityAgent = target.IdentityAgent
|
||||
}
|
||||
return bastion, err
|
||||
}
|
||||
|
||||
// parseLiteralProxyJump recognizes a `[user@]host[:port]` ProxyJump literal,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
@@ -766,3 +767,92 @@ func TestSSHManagedBrokerResumeForcesFreshAgentctlWithNewLease(t *testing.T) {
|
||||
t.Fatal("connection metadata required for fresh SSH launch was removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandIdentityAgentOpenSSHSyntax(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("AGENT_DIR", filepath.Join(home, "agents"))
|
||||
t.Setenv("LEGACY_AGENT", filepath.Join(home, "legacy.sock"))
|
||||
t.Setenv("SSH_AUTH_SOCK", filepath.Join(home, "env.sock"))
|
||||
target := &SSHTarget{
|
||||
Host: "resolved.internal", Port: 2222, User: "deploy",
|
||||
ProxyJump: "jump-alias", OriginalHost: "prod",
|
||||
}
|
||||
|
||||
cases := map[string]string{
|
||||
"~/.ssh/agent.sock": filepath.Join(home, ".ssh", "agent.sock"),
|
||||
"${AGENT_DIR}/%n-%h-%p-%r-%j-%%.sock": filepath.Join(home, "agents", "prod-resolved.internal-2222-deploy-jump-alias-%.sock"),
|
||||
"$LEGACY_AGENT": filepath.Join(home, "legacy.sock"),
|
||||
"SSH_AUTH_SOCK": filepath.Join(home, "env.sock"),
|
||||
"@abstract-agent": "@abstract-agent",
|
||||
}
|
||||
for input, want := range cases {
|
||||
got, err := expandIdentityAgent(input, target)
|
||||
if err != nil {
|
||||
t.Errorf("expandIdentityAgent(%q): %v", input, err)
|
||||
} else if got != want {
|
||||
t.Errorf("expandIdentityAgent(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
allTokens, err := expandIdentityAgent("%d-%i-%k-%L-%l-%u", target)
|
||||
if err != nil || allTokens == "" || !strings.Contains(allTokens, "prod") {
|
||||
t.Fatalf("all token expansion = %q, %v", allTokens, err)
|
||||
}
|
||||
for _, input := range []string{"${MISSING_IDENTITY_AGENT}", "%C", "%Z", "trailing%"} {
|
||||
if _, err := expandIdentityAgent(input, target); err == nil {
|
||||
t.Errorf("expandIdentityAgent(%q) unexpectedly succeeded", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandIdentityAgentRejectsUnsetLegacyVariable(t *testing.T) {
|
||||
const variable = "MISSING_IDENTITY_AGENT"
|
||||
previous, wasSet := os.LookupEnv(variable)
|
||||
if err := os.Unsetenv(variable); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if wasSet {
|
||||
_ = os.Setenv(variable, previous)
|
||||
return
|
||||
}
|
||||
_ = os.Unsetenv(variable)
|
||||
})
|
||||
t.Setenv("SSH_AUTH_SOCK", "/tmp/fallback.sock")
|
||||
|
||||
got, err := expandIdentityAgent("$"+variable, &SSHTarget{Host: "example.com"})
|
||||
if err == nil {
|
||||
t.Fatalf("expandIdentityAgent() returned %q without an error", got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), variable) {
|
||||
t.Fatalf("error = %v, want missing variable name %q", err, variable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandIdentityAgentEnvironmentDoesNotReprocessSubstitutedTokens(t *testing.T) {
|
||||
const childEnv = "KANDEV_TEST_EXPAND_IDENTITY_AGENT_ENV"
|
||||
if os.Getenv(childEnv) == "1" {
|
||||
t.Setenv("AGENT", "${AGENT}")
|
||||
got, err := expandIdentityAgentEnvironment("${AGENT}")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "${AGENT}" {
|
||||
t.Fatalf("expanded value = %q, want the substituted token to remain literal", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run", "^TestExpandIdentityAgentEnvironmentDoesNotReprocessSubstitutedTokens$")
|
||||
cmd.Env = append(os.Environ(), childEnv+"=1")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
t.Fatalf("environment expansion did not terminate")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("child test failed: %v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
@@ -23,6 +24,9 @@ func newTestHomeDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
previousSystemConfig := sshSystemConfigPath
|
||||
sshSystemConfigPath = filepath.Join(home, "missing-system-ssh-config")
|
||||
t.Cleanup(func() { sshSystemConfigPath = previousSystemConfig })
|
||||
if err := os.MkdirAll(filepath.Join(home, ".ssh"), 0o700); err != nil {
|
||||
t.Fatalf("create fake ~/.ssh: %v", err)
|
||||
}
|
||||
@@ -653,3 +657,198 @@ func writeKnownHosts(t *testing.T, home, host string, key ssh.PublicKey) {
|
||||
t.Fatalf("write known_hosts: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityAgentInheritanceAndPrecedence(t *testing.T) {
|
||||
home := newTestHomeDir(t)
|
||||
writeSSHConfig(t, home, `Host agent-host
|
||||
HostName target.internal
|
||||
User deploy
|
||||
IdentityAgent ~/.agents/%n.sock
|
||||
Host file-host
|
||||
HostName target.internal
|
||||
User deploy
|
||||
IdentityAgent /tmp/config-agent.sock
|
||||
`)
|
||||
|
||||
t.Run("explicit agent selection still inherits IdentityAgent", func(t *testing.T) {
|
||||
target, err := ResolveSSHTarget(SSHConnConfig{
|
||||
HostAlias: "agent-host", IdentitySource: SSHIdentitySourceAgent,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(home, ".agents", "agent-host.sock")
|
||||
if target.IdentityAgent != want {
|
||||
t.Fatalf("IdentityAgent = %q, want %q", target.IdentityAgent, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("configured IdentityAgent selects agent without global socket", func(t *testing.T) {
|
||||
t.Setenv("SSH_AUTH_SOCK", "")
|
||||
target, err := ResolveSSHTarget(SSHConnConfig{HostAlias: "agent-host"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if target.IdentitySource != SSHIdentitySourceAgent {
|
||||
t.Fatalf("IdentitySource = %q, want agent", target.IdentitySource)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit file source ignores IdentityAgent", func(t *testing.T) {
|
||||
key := writeTestIdentityFile(t)
|
||||
target, err := ResolveSSHTarget(SSHConnConfig{
|
||||
HostAlias: "file-host", IdentitySource: SSHIdentitySourceFile, IdentityFile: key,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if target.IdentityAgent != "" || target.IdentityFile != key {
|
||||
t.Fatalf("target auth = %+v", target)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildAuthMethodsIdentityAgentOverridesEnvironment(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configuredPath := filepath.Join(dir, "configured.sock")
|
||||
envPath := filepath.Join(dir, "env.sock")
|
||||
configured, err := net.Listen("unix", configuredPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = configured.Close() })
|
||||
envListener, err := net.Listen("unix", envPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = envListener.Close() })
|
||||
t.Setenv("SSH_AUTH_SOCK", envPath)
|
||||
|
||||
_, cleanup, err := buildAuthMethods(&SSHTarget{
|
||||
IdentitySource: SSHIdentitySourceAgent,
|
||||
IdentityAgent: configuredPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanup()
|
||||
|
||||
_ = configured.(*net.UnixListener).SetDeadline(time.Now().Add(time.Second))
|
||||
conn, err := configured.Accept()
|
||||
if err != nil {
|
||||
t.Fatalf("configured IdentityAgent was not dialed: %v", err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
_ = envListener.(*net.UnixListener).SetDeadline(time.Now().Add(20 * time.Millisecond))
|
||||
if conn, err := envListener.Accept(); err == nil {
|
||||
_ = conn.Close()
|
||||
t.Fatal("SSH_AUTH_SOCK was dialed instead of IdentityAgent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityAgentNoneDisablesEnvironmentFallback(t *testing.T) {
|
||||
t.Setenv("SSH_AUTH_SOCK", "/tmp/should-not-be-used.sock")
|
||||
_, cleanup, err := buildAuthMethods(&SSHTarget{
|
||||
IdentitySource: SSHIdentitySourceAgent,
|
||||
IdentityAgent: "none",
|
||||
})
|
||||
cleanup()
|
||||
if err == nil || !strings.Contains(err.Error(), "IdentityAgent is set to none") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBastionAuthPrecedence(t *testing.T) {
|
||||
home := newTestHomeDir(t)
|
||||
jumpKey := writeTestIdentityFile(t)
|
||||
writeSSHConfig(t, home, fmt.Sprintf(`Host jump-agent
|
||||
HostName jump.internal
|
||||
User jumper
|
||||
IdentityAgent ~/.agents/jump.sock
|
||||
Host jump-file
|
||||
HostName jump.internal
|
||||
User jumper
|
||||
IdentityFile %s
|
||||
Host jump-fallback
|
||||
HostName jump.internal
|
||||
User jumper
|
||||
`, jumpKey))
|
||||
|
||||
t.Run("jump alias IdentityAgent overrides target socket", func(t *testing.T) {
|
||||
bastion, err := resolveBastion(&SSHTarget{ProxyJump: "jump-agent", IdentitySource: SSHIdentitySourceAgent, IdentityAgent: "/target.sock"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(home, ".agents", "jump.sock")
|
||||
if bastion.IdentitySource != SSHIdentitySourceAgent || bastion.IdentityAgent != want {
|
||||
t.Fatalf("bastion auth = %+v, want agent %q", bastion, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jump alias IdentityFile overrides target agent", func(t *testing.T) {
|
||||
bastion, err := resolveBastion(&SSHTarget{ProxyJump: "jump-file", IdentitySource: SSHIdentitySourceAgent, IdentityAgent: "/target.sock"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bastion.IdentitySource != SSHIdentitySourceFile || bastion.IdentityFile != jumpKey {
|
||||
t.Fatalf("bastion auth = %+v", bastion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alias without auth falls back to target", func(t *testing.T) {
|
||||
bastion, err := resolveBastion(&SSHTarget{ProxyJump: "jump-fallback", IdentitySource: SSHIdentitySourceAgent, IdentityAgent: "/target.sock"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bastion.IdentityAgent != "/target.sock" {
|
||||
t.Fatalf("bastion auth = %+v", bastion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("literal jump falls back to target", func(t *testing.T) {
|
||||
bastion, err := resolveBastion(&SSHTarget{ProxyJump: "jumper@jump.internal:22", IdentitySource: SSHIdentitySourceAgent, IdentityAgent: "/target.sock"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bastion.IdentityAgent != "/target.sock" {
|
||||
t.Fatalf("bastion auth = %+v", bastion)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEffectiveSSHConfigReadsUserAndSystemFresh(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
if err := os.MkdirAll(filepath.Join(home, ".ssh"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
systemPath := filepath.Join(t.TempDir(), "ssh_config")
|
||||
previousSystemConfig := sshSystemConfigPath
|
||||
sshSystemConfigPath = systemPath
|
||||
t.Cleanup(func() { sshSystemConfigPath = previousSystemConfig })
|
||||
if err := os.WriteFile(systemPath, []byte("Host system-only\n HostName from-system.internal\n User system-user\n IdentityAgent /system-agent.sock\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeSSHConfig(t, home, "Host shared\n HostName first-user.internal\n User user-one\n")
|
||||
|
||||
systemTarget, err := ResolveSSHTarget(SSHConnConfig{HostAlias: "system-only", IdentitySource: SSHIdentitySourceAgent})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if systemTarget.Host != "from-system.internal" || systemTarget.User != "system-user" || systemTarget.IdentityAgent != "/system-agent.sock" {
|
||||
t.Fatalf("system target = %+v", systemTarget)
|
||||
}
|
||||
|
||||
first, err := ResolveSSHTarget(SSHConnConfig{HostAlias: "shared", IdentitySource: SSHIdentitySourceAgent})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeSSHConfig(t, home, "Host shared\n HostName second-user.internal\n User user-two\n")
|
||||
second, err := ResolveSSHTarget(SSHConnConfig{HostAlias: "shared", IdentitySource: SSHIdentitySourceAgent})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Host != "first-user.internal" || second.Host != "second-user.internal" || second.User != "user-two" {
|
||||
t.Fatalf("fresh config results = first:%+v second:%+v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@
|
||||
"sshFailedToSaveShell": "Failed to save shell",
|
||||
"sshFingerprintChanged": "Warning: this fingerprint differs from the one currently pinned (<1>{{fingerprint}}</1>). Trusting it overwrites the pinned key. If you didn’t expect a host re-key, stop here.",
|
||||
"sshFingerprintObserved": "Host fingerprint observed:",
|
||||
"sshHostAliasHint": "If set, inherits HostName / Port / User / IdentityFile / ProxyJump from your config.",
|
||||
"sshHostAliasHint": "If set, inherits HostName / Port / User / IdentityAgent / IdentityFile / ProxyJump from your config.",
|
||||
"sshHostAliasLabel": "Host alias from {{path}} (optional)",
|
||||
"sshIdentityFileHint": "Passphrase-protected keys must be loaded into ssh-agent first.",
|
||||
"sshIdentityFilePath": "Identity file path",
|
||||
|
||||
@@ -219,7 +219,7 @@
|
||||
"sshFailedToSaveShell": "Ƒàĩĺēď ţō śàvē śĥēĺĺ",
|
||||
"sshFingerprintChanged": "Ŵàŕńĩńĝ: ţĥĩś ƒĩńĝēŕƥŕĩńţ ďĩƒƒēŕś ƒŕōḿ ţĥē ōńē ćũŕŕēńţĺŷ ƥĩńńēď (<1>{{fingerprint}}</1>). Ţŕũśţĩńĝ ĩţ ōvēŕŵŕĩţēś ţĥē ƥĩńńēď ķēŷ. Ĩƒ ŷōũ ďĩďń’ţ ēxƥēćţ à ĥōśţ ŕē-ķēŷ, śţōƥ ĥēŕē.",
|
||||
"sshFingerprintObserved": "Ĥōśţ ƒĩńĝēŕƥŕĩńţ ōƀśēŕvēď:",
|
||||
"sshHostAliasHint": "Ĩƒ śēţ, ĩńĥēŕĩţś ĤōśţŃàḿē / Ƥōŕţ / Ũśēŕ / ĨďēńţĩţŷƑĩĺē / ƤŕōxŷĴũḿƥ ƒŕōḿ ŷōũŕ ćōńƒĩĝ.",
|
||||
"sshHostAliasHint": "Ĩƒ śēţ, ĩńĥēŕĩţś ĤōśţŃàḿē / Ƥōŕţ / Ũśēŕ / ĨďēńţĩţŷÀĝēńţ / ĨďēńţĩţŷƑĩĺē / ƤŕōxŷĴũḿƥ ƒŕōḿ ŷōũŕ ćōńƒĩĝ.",
|
||||
"sshHostAliasLabel": "Ĥōśţ àĺĩàś ƒŕōḿ {{path}} (ōƥţĩōńàĺ)",
|
||||
"sshIdentityFileHint": "Ƥàśśƥĥŕàśē-ƥŕōţēćţēď ķēŷś ḿũśţ ƀē ĺōàďēď ĩńţō śśĥ-àĝēńţ ƒĩŕśţ.",
|
||||
"sshIdentityFilePath": "Ĩďēńţĩţŷ ƒĩĺē ƥàţĥ",
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
"sshFailedToSaveShell": "Não foi possível guardar a shell",
|
||||
"sshFingerprintChanged": "Aviso: esta impressão digital difere da que está atualmente afixada (<1>{{fingerprint}}</1>). Confiar nela substitui a chave afixada. Se não estava à espera de uma mudança de chave do anfitrião, pare aqui.",
|
||||
"sshFingerprintObserved": "Impressão digital do anfitrião observada:",
|
||||
"sshHostAliasHint": "Se for definido, herda HostName / Port / User / IdentityFile / ProxyJump da sua configuração.",
|
||||
"sshHostAliasHint": "Se for definido, herda HostName / Port / User / IdentityAgent / IdentityFile / ProxyJump da sua configuração.",
|
||||
"sshHostAliasLabel": "Alias de anfitrião de {{path}} (opcional)",
|
||||
"sshIdentityFileHint": "As chaves protegidas por frase-passe têm de ser carregadas primeiro no ssh-agent.",
|
||||
"sshIdentityFilePath": "Caminho do ficheiro de identidade",
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
"sshFailedToSaveShell": "保存 shell 失败",
|
||||
"sshFingerprintChanged": "警告:此指纹与当前固定的指纹不同(<1>{{fingerprint}}</1>)。信任后将覆盖已固定的密钥。若你未预期主机重新生成密钥,请停止操作。",
|
||||
"sshFingerprintObserved": "观察到的主机指纹:",
|
||||
"sshHostAliasHint": "若已设置,将从配置继承 HostName / Port / User / IdentityFile / ProxyJump。",
|
||||
"sshHostAliasHint": "若已设置,将从配置继承 HostName / Port / User / IdentityAgent / IdentityFile / ProxyJump。",
|
||||
"sshHostAliasLabel": "来自 {{path}} 的主机别名(可选)",
|
||||
"sshIdentityFileHint": "受密码保护的密钥须先加载到 ssh-agent。",
|
||||
"sshIdentityFilePath": "身份文件路径",
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
"sshFailedToSaveShell": "儲存 shell 失敗",
|
||||
"sshFingerprintChanged": "警告:此指紋與目前固定的指紋不同(<1>{{fingerprint}}</1>)。信任後將覆蓋已固定的密鑰。若你未預期主機重新產生密鑰,請停止操作。",
|
||||
"sshFingerprintObserved": "觀察到的主機指紋:",
|
||||
"sshHostAliasHint": "若已設定,將從設定繼承 HostName / Port / User / IdentityFile / ProxyJump。",
|
||||
"sshHostAliasHint": "若已設定,將從設定繼承 HostName / Port / User / IdentityAgent / IdentityFile / ProxyJump。",
|
||||
"sshHostAliasLabel": "來自 {{path}} 的主機別名(可選)",
|
||||
"sshIdentityFileHint": "受密碼保護的密鑰須先載入到 ssh-agent。",
|
||||
"sshIdentityFilePath": "身份檔案路徑",
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
"sshFailedToSaveShell": "儲存 shell 失敗",
|
||||
"sshFingerprintChanged": "警告:此指紋與目前固定的指紋不同(<1>{{fingerprint}}</1>)。信任後將覆蓋已固定的金鑰。若你未預期主機重新產生金鑰,請停止操作。",
|
||||
"sshFingerprintObserved": "觀察到的主機指紋:",
|
||||
"sshHostAliasHint": "若已設定,將從設定繼承 HostName / Port / User / IdentityFile / ProxyJump。",
|
||||
"sshHostAliasHint": "若已設定,將從設定繼承 HostName / Port / User / IdentityAgent / IdentityFile / ProxyJump。",
|
||||
"sshHostAliasLabel": "來自 {{path}} 的主機別名(可選)",
|
||||
"sshIdentityFileHint": "受密碼保護的金鑰須先載入到 ssh-agent。",
|
||||
"sshIdentityFilePath": "身分檔案路徑",
|
||||
|
||||
@@ -309,14 +309,14 @@ Released Kandev bundles include helpers for all four platform combinations. The
|
||||
|
||||
### Create the connection
|
||||
|
||||
Choose **Settings > Executors > SSH**. Enter a name plus either a Host or a host alias from `~/.ssh/config`. The backend resolver can inherit `HostName`, `Port`, `User`, `IdentityFile`, and one `ProxyJump`; explicit form values win. The current create form defaults and persists Port `22` and identity source `ssh-agent`, so enter a non-22 alias port and desired identity source explicitly instead of assuming those two values inherit. `IdentitiesOnly` and arbitrary OpenSSH directives are not consumed by Kandev.
|
||||
Choose **Settings > Executors > SSH**. Enter a name plus either a Host or a host alias from your OpenSSH client configuration. The backend resolver can inherit `HostName`, `Port`, `User`, `IdentityAgent`, `IdentityFile`, and one `ProxyJump`; explicit form values win. The current create form defaults and persists Port `22` and identity source `ssh-agent`, so enter a non-22 alias port and desired identity source explicitly instead of assuming those two values inherit. `IdentitiesOnly` and arbitrary OpenSSH directives are not consumed by Kandev.
|
||||
|
||||
Authentication choices are:
|
||||
|
||||
- `ssh-agent (SSH_AUTH_SOCK)`; or
|
||||
- ssh-agent, using the host's expanded `IdentityAgent` when configured and otherwise `$SSH_AUTH_SOCK`; or
|
||||
- an unencrypted private-key file.
|
||||
|
||||
Password and keyboard-interactive authentication are not supported. A passphrase-protected key file must first be loaded with `ssh-add`, then used through ssh-agent.
|
||||
`IdentityAgent none` disables agent authentication for that host. Kandev expands `~`, `${VAR}`, whole-value `$VAR`, `SSH_AUTH_SOCK`, and the `%%`, `%d`, `%h`, `%i`, `%j`, `%k`, `%L`, `%l`, `%n`, `%p`, `%r`, and `%u` OpenSSH tokens in agent socket paths; `%C` is not supported. Password and keyboard-interactive authentication are not supported. A passphrase-protected key file must first be loaded with `ssh-add`, then used through ssh-agent.
|
||||
|
||||
Run **Test Connection**, independently verify the observed SHA256 host fingerprint, select **Trust this host**, then save. Kandev pins the final target fingerprint and refuses a changed key. With ProxyJump, the target remains pinned, but bastion handling is weaker: Kandev checks `~/.ssh/known_hosts` when available and rejects a changed known key, while an unknown bastion key is accepted on first use. Verify and pre-populate the bastion key yourself.
|
||||
|
||||
|
||||
@@ -70,9 +70,9 @@ Multiple sessions in the *same* task share the same worktree on disk (same files
|
||||
|
||||
### Auth, connectivity, and host-key trust
|
||||
|
||||
- **Auth: SSH key file + system ssh-agent.** Authentication via `golang.org/x/crypto/ssh` using either an explicit `IdentityFile` or the user's running `ssh-agent` (`$SSH_AUTH_SOCK`) — covers 1Password / Secretive / Yubikey / forwarded-agent users who don't keep raw keys on disk. **Passphrase-protected keys are not handled in kandev** — users must load them into `ssh-agent` themselves. Password and keyboard-interactive auth are not supported in v1.
|
||||
- **Auth: SSH key file + ssh-agent.** Authentication via `golang.org/x/crypto/ssh` uses either an explicit `IdentityFile` or an ssh-agent. A per-host OpenSSH `IdentityAgent` overrides the backend process's `$SSH_AUTH_SOCK`, covering 1Password / Secretive / Yubikey / forwarded-agent users whose agent socket differs from the desktop process environment. `IdentityAgent none` disables agent authentication for that host. **Passphrase-protected keys are not handled in kandev** — users must load them into `ssh-agent` themselves. Password and keyboard-interactive auth are not supported in v1.
|
||||
|
||||
- **`~/.ssh/config` inheritance.** When a `host_alias` is configured, kandev parses `~/.ssh/config` to inherit `HostName`, `Port`, `User`, `IdentityFile`, `ProxyJump`, and `IdentitiesOnly` from the user's existing config. A user whose terminal already does `ssh prod` can paste `prod` into kandev and have it just work.
|
||||
- **OpenSSH config inheritance.** When a `host_alias` is configured, kandev resolves user and system OpenSSH client configuration to inherit `HostName`, `Port`, `User`, `IdentityAgent`, `IdentityFile`, and one `ProxyJump`. `IdentityAgent` supports `~`, `${VAR}`, whole-value `$VAR`, `SSH_AUTH_SOCK`, and percent tokens `%%`, `%d`, `%h`, `%i`, `%j`, `%k`, `%L`, `%l`, `%n`, `%p`, `%r`, and `%u`; `%C` is not supported. A user whose terminal already does `ssh prod` can paste `prod` into kandev and have it just work.
|
||||
|
||||
- **Connectivity: direct, ProxyJump, mesh-VPN.** Direct TCP to `host:port` (default 22). `ProxyJump` (single bastion in v1; chained jumps deferred) implemented natively via the Go SSH client. Tailscale / WireGuard / corporate VPN: "just works" when the kandev backend process is on the same network namespace.
|
||||
|
||||
@@ -183,6 +183,12 @@ Multiple sessions in the *same* task share the same worktree on disk (same files
|
||||
|
||||
- **GIVEN** a user keeps their SSH key in 1Password (no key file on disk) with their agent running, **WHEN** they select "ssh-agent" as identity source, **THEN** the connection succeeds without kandev ever touching key material; passphrase-protected keys without an agent are explicitly rejected with a "load this key into ssh-agent first" message.
|
||||
|
||||
- **GIVEN** a host alias sets `IdentityAgent` to a working agent socket while the backend's `$SSH_AUTH_SOCK` is empty or stale, **WHEN** the user selects "ssh-agent", **THEN** kandev connects through the alias-specific socket.
|
||||
|
||||
- **GIVEN** a target and its ProxyJump alias select different `IdentityAgent` sockets, **WHEN** kandev connects through the bastion, **THEN** each hop uses its own configured agent; a jump without an auth directive falls back to the target identity.
|
||||
|
||||
- **GIVEN** a literal `[user@]host[:port]` ProxyJump has no matching host stanza, **WHEN** kandev connects through it, **THEN** the jump inherits the target identity.
|
||||
|
||||
- **GIVEN** a task already has session A running on an SSH host, **WHEN** the user opens a second session B on the same task, **THEN** kandev reuses the existing task dir (no re-clone), launches a second agentctl on a different remote port with its own local SSH forward over the same SSH connection, and both sessions stream independently against the same worktree.
|
||||
|
||||
- **GIVEN** two different tasks are running against the same SSH host, **WHEN** the kandev backend restarts, **THEN** `ResumeRemoteInstance` opens one SSH connection to the host, re-forwards each session's recorded remote port through that single connection, verifies `kill -0 <pid>` on each, and the UI reconnects without losing in-progress turns.
|
||||
|
||||
Reference in New Issue
Block a user