| 1 | package remote |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "net" |
| 6 | "os" |
| 7 | "os/user" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | |
| 11 | "reasonix/internal/config" |
| 12 | ) |
| 13 | |
| 14 | // ResolvedHost is a fully resolved dial target: explicit [remote] TOML fields |
| 15 | // layered over ~/.ssh/config values (when use_ssh_config) over defaults. |
| 16 | type ResolvedHost struct { |
| 17 | Name string // config entry name, or the raw target for ad-hoc dials |
| 18 | HostName string // network address to dial |
| 19 | Port int |
| 20 | User string |
| 21 | IdentityFile string // explicit key path; empty => agent/default identities |
| 22 | IdentityFiles []string // ordered effective ssh_config identities |
| 23 | IdentityFileNone bool // ssh_config explicitly suppresses default identity files |
| 24 | IdentitiesOnly bool // ssh_config IdentitiesOnly: never offer unrelated agent keys |
| 25 | PassphraseEnv string // credential env var name for the key passphrase |
| 26 | PasswordEnv string // credential env var name for password auth |
| 27 | ProxyJump []string // resolved jump chain, in dial order |
| 28 | Workspace string // default remote workspace directory |
| 29 | ServeInstall string // auto|npm|upload|never |
| 30 | Forwards []config.RemoteForwardEntry |
| 31 | } |
| 32 | |
| 33 | // Addr is the host:port dial string. |
| 34 | func (h ResolvedHost) Addr() string { |
| 35 | return net.JoinHostPort(h.HostName, strconv.Itoa(h.Port)) |
| 36 | } |
| 37 | |
| 38 | // Label is the display form user@host:port. |
| 39 | func (h ResolvedHost) Label() string { |
| 40 | label := h.HostName |
| 41 | if h.User != "" { |
| 42 | label = h.User + "@" + label |
| 43 | } |
| 44 | if h.Port != 0 && h.Port != 22 { |
| 45 | label += ":" + strconv.Itoa(h.Port) |
| 46 | } |
| 47 | return label |
| 48 | } |
| 49 | |
| 50 | // ParseTarget splits an ad-hoc "[user@]host[:port]" target. IPv6 literals use |
| 51 | // the bracketed form "[::1]:22". |
| 52 | func ParseTarget(s string) (userName, host string, port int, err error) { |
| 53 | s = strings.TrimSpace(s) |
| 54 | if s == "" { |
| 55 | return "", "", 0, fmt.Errorf("empty ssh target") |
| 56 | } |
| 57 | if at := strings.LastIndex(s, "@"); at >= 0 { |
| 58 | userName, s = s[:at], s[at+1:] |
| 59 | if userName == "" || s == "" { |
| 60 | return "", "", 0, fmt.Errorf("invalid ssh target %q", s) |
| 61 | } |
| 62 | } |
| 63 | host = s |
| 64 | port = 0 |
| 65 | if strings.HasPrefix(s, "[") { |
| 66 | // Bracketed IPv6, optionally with :port. |
| 67 | end := strings.Index(s, "]") |
| 68 | if end < 0 { |
| 69 | return "", "", 0, fmt.Errorf("invalid ssh target %q: unclosed '['", s) |
| 70 | } |
| 71 | host = s[1:end] |
| 72 | rest := s[end+1:] |
| 73 | if rest != "" { |
| 74 | if !strings.HasPrefix(rest, ":") { |
| 75 | return "", "", 0, fmt.Errorf("invalid ssh target %q", s) |
| 76 | } |
| 77 | port, err = parsePort(rest[1:]) |
| 78 | if err != nil { |
| 79 | return "", "", 0, err |
| 80 | } |
| 81 | } |
| 82 | } else if i := strings.LastIndex(s, ":"); i >= 0 { |
| 83 | if strings.Count(s, ":") > 1 { |
| 84 | // Bare IPv6 literal without a port. |
| 85 | host = s |
| 86 | } else { |
| 87 | host = s[:i] |
| 88 | port, err = parsePort(s[i+1:]) |
| 89 | if err != nil { |
| 90 | return "", "", 0, err |
| 91 | } |
| 92 | } |
| 93 | } |
| 94 | if host == "" { |
| 95 | return "", "", 0, fmt.Errorf("invalid ssh target %q: empty host", s) |
| 96 | } |
| 97 | return userName, host, port, nil |
| 98 | } |
| 99 | |
| 100 | func parsePort(s string) (int, error) { |
| 101 | p, err := strconv.Atoi(s) |
| 102 | if err != nil || p <= 0 || p > 65535 { |
| 103 | return 0, fmt.Errorf("invalid ssh port %q", s) |
| 104 | } |
| 105 | return p, nil |
| 106 | } |
| 107 | |
| 108 | // ResolveHost builds the dial target for a configured host name or an ad-hoc |
| 109 | // "[user@]host[:port]" target. Field precedence: explicit TOML value → |
| 110 | // ~/.ssh/config value (only when the entry sets use_ssh_config, or for ad-hoc |
| 111 | // targets when sshCfg is non-nil) → default (port 22, current OS user). |
| 112 | func ResolveHost(cfg *config.Config, nameOrTarget string, sshCfg *SSHConfigSource) (ResolvedHost, error) { |
| 113 | if cfg != nil { |
| 114 | if e, ok := cfg.RemoteHost(nameOrTarget); ok { |
| 115 | return resolveEntry(e, sshCfg) |
| 116 | } |
| 117 | } |
| 118 | userName, host, port, err := ParseTarget(nameOrTarget) |
| 119 | if err != nil { |
| 120 | return ResolvedHost{}, err |
| 121 | } |
| 122 | r := ResolvedHost{Name: nameOrTarget, HostName: host, Port: port, User: userName} |
| 123 | if err := applySSHConfig(&r, host, sshCfg); err != nil { |
| 124 | return ResolvedHost{}, err |
| 125 | } |
| 126 | applyHostDefaults(&r) |
| 127 | return r, nil |
| 128 | } |
| 129 | |
| 130 | // ResolveJumpHosts resolves every ProxyJump token through the same Reasonix |
| 131 | // host table and ~/.ssh/config layers as the final target. A jump entry's own |
| 132 | // ProxyJump is deliberately cleared: the caller-provided chain is already the |
| 133 | // complete left-to-right route, and recursively expanding nested chains would |
| 134 | // make ordering and credential ownership ambiguous. |
| 135 | func ResolveJumpHosts(cfg *config.Config, chain []string, sshCfg *SSHConfigSource) ([]ResolvedHost, error) { |
| 136 | out := make([]ResolvedHost, 0, len(chain)) |
| 137 | for i, raw := range chain { |
| 138 | hop, err := ResolveHost(cfg, raw, sshCfg) |
| 139 | if err != nil { |
| 140 | return nil, fmt.Errorf("proxy jump %d (%q): %w", i+1, raw, err) |
| 141 | } |
| 142 | hop.ProxyJump = nil |
| 143 | out = append(out, hop) |
| 144 | } |
| 145 | return out, nil |
| 146 | } |
| 147 | |
| 148 | func resolveEntry(e config.RemoteHostEntry, sshCfg *SSHConfigSource) (ResolvedHost, error) { |
| 149 | r := ResolvedHost{ |
| 150 | Name: e.Name, |
| 151 | HostName: strings.TrimSpace(e.Host), |
| 152 | Port: e.Port, |
| 153 | User: strings.TrimSpace(e.User), |
| 154 | IdentityFile: strings.TrimSpace(e.IdentityFile), |
| 155 | PassphraseEnv: strings.TrimSpace(e.PassphraseEnv), |
| 156 | PasswordEnv: strings.TrimSpace(e.PasswordEnv), |
| 157 | Workspace: strings.TrimSpace(e.Workspace), |
| 158 | ServeInstall: e.ServeInstallMode(), |
| 159 | Forwards: e.Forwards, |
| 160 | } |
| 161 | if j := strings.TrimSpace(e.ProxyJump); j != "" { |
| 162 | r.ProxyJump = splitJumpChain(j) |
| 163 | } |
| 164 | if e.UseSSHConfig { |
| 165 | // Host is the persisted lookup key. New imports store the SSH alias here; |
| 166 | // legacy imports store a resolved hostname snapshot. Never substitute Name: |
| 167 | // it is a user-facing label and may collide with an unrelated SSH alias. |
| 168 | if err := applySSHConfig(&r, r.HostName, sshCfg); err != nil { |
| 169 | return ResolvedHost{}, err |
| 170 | } |
| 171 | } |
| 172 | applyHostDefaults(&r) |
| 173 | if r.HostName == "" { |
| 174 | return ResolvedHost{}, fmt.Errorf("remote host %q: empty hostname after resolution", e.Name) |
| 175 | } |
| 176 | return r, nil |
| 177 | } |
| 178 | |
| 179 | // applySSHConfig fills unset fields from ~/.ssh/config for alias. |
| 180 | func applySSHConfig(r *ResolvedHost, alias string, sshCfg *SSHConfigSource) error { |
| 181 | if sshCfg == nil || alias == "" { |
| 182 | return nil |
| 183 | } |
| 184 | effective, err := sshCfg.EffectiveWithError(alias) |
| 185 | if err != nil { |
| 186 | return err |
| 187 | } |
| 188 | if hn := effective.HostName; hn != "" && hn != alias { |
| 189 | // An explicit TOML host that matched an alias keeps the alias only as |
| 190 | // the lookup key; the network target comes from ssh_config. |
| 191 | r.HostName = hn |
| 192 | } |
| 193 | if r.Port == 0 { |
| 194 | r.Port = effective.Port |
| 195 | } |
| 196 | if r.User == "" { |
| 197 | r.User = effective.User |
| 198 | } |
| 199 | if r.IdentityFile == "" { |
| 200 | r.IdentityFiles = append([]string(nil), effective.IdentityFiles...) |
| 201 | r.IdentityFileNone = effective.IdentityFileNone |
| 202 | if len(r.IdentityFiles) > 0 { |
| 203 | r.IdentityFile = r.IdentityFiles[0] |
| 204 | } |
| 205 | } else if len(r.IdentityFiles) == 0 { |
| 206 | r.IdentityFiles = []string{r.IdentityFile} |
| 207 | r.IdentityFileNone = false |
| 208 | } |
| 209 | if len(r.ProxyJump) == 0 { |
| 210 | if j := effective.ProxyJump; j != "" { |
| 211 | r.ProxyJump = splitJumpChain(j) |
| 212 | } |
| 213 | } |
| 214 | r.IdentitiesOnly = effective.IdentitiesOnly |
| 215 | return nil |
| 216 | } |
| 217 | |
| 218 | func applyHostDefaults(r *ResolvedHost) { |
| 219 | if r.Port == 0 { |
| 220 | r.Port = 22 |
| 221 | } |
| 222 | if r.User == "" { |
| 223 | if u, err := user.Current(); err == nil && u.Username != "" { |
| 224 | r.User = u.Username |
| 225 | } else if env := os.Getenv("USER"); env != "" { |
| 226 | r.User = env |
| 227 | } |
| 228 | } |
| 229 | if r.ServeInstall == "" { |
| 230 | r.ServeInstall = "auto" |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | func splitJumpChain(s string) []string { |
| 235 | parts := strings.Split(s, ",") |
| 236 | out := make([]string, 0, len(parts)) |
| 237 | for _, p := range parts { |
| 238 | if p = strings.TrimSpace(p); p != "" && !strings.EqualFold(p, "none") { |
| 239 | out = append(out, p) |
| 240 | } |
| 241 | } |
| 242 | return out |
| 243 | } |
| 244 |