返回 DeepSeek-Reasonix
host.go
根目录 / internal / remote / host.go
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
244 lines GO