返回 DeepSeek-Reasonix
remote.go
根目录 / internal / config / remote.go
1 package config
2
3 import (
4 "fmt"
5 "net"
6 "strconv"
7 "strings"
8 )
9
10 // RemoteConfig is the [remote] section: SSH hosts the remote module may
11 // connect to, and their default forwards/workspaces. Like [secrets] it is a
12 // user-global security control — LoadForRoot pins it back to the user config
13 // after the project merge so a cloned repo's reasonix.toml can never inject
14 // hosts, jump chains, or forwards.
15 type RemoteConfig struct {
16 // ImportSSHConfig surfaces ~/.ssh/config aliases in `reasonix remote import`.
17 ImportSSHConfig bool `toml:"import_ssh_config"`
18 Hosts []RemoteHostEntry `toml:"hosts"`
19 }
20
21 // RemoteHostEntry describes one SSH target. Secrets follow the provider
22 // idiom: the entry names credential env vars (passphrase_env/password_env);
23 // values live in Reasonix's global .env, never in TOML. identity_file is a
24 // path — private key material itself is never stored by Reasonix.
25 type RemoteHostEntry struct {
26 Name string `toml:"name"`
27 Host string `toml:"host"`
28 Port int `toml:"port"` // 0 => 22 (or ssh_config value)
29 User string `toml:"user"`
30 IdentityFile string `toml:"identity_file"`
31 PassphraseEnv string `toml:"passphrase_env"`
32 PasswordEnv string `toml:"password_env"`
33 ProxyJump string `toml:"proxy_jump"` // OpenSSH ProxyJump syntax, comma-separated chain
34 Workspace string `toml:"workspace"` // default remote workspace dir
35 ServeInstall string `toml:"serve_install"` // remote CLI: auto|npm|upload|never
36 UseSSHConfig bool `toml:"use_ssh_config"` // layer ~/.ssh/config values under unset fields
37 Forwards []RemoteForwardEntry `toml:"forwards"`
38 }
39
40 // RemoteForwardEntry is a persisted port-forward rule applied on connect.
41 type RemoteForwardEntry struct {
42 Type string `toml:"type"` // "local" (-L) | "remote" (-R)
43 Bind string `toml:"bind"` // "127.0.0.1:8080" or bare port => 127.0.0.1:<port>
44 Target string `toml:"target"` // host:port on the other side
45 }
46
47 // RemoteServeInstallModes are the accepted serve_install values.
48 var RemoteServeInstallModes = []string{"auto", "npm", "upload", "never"}
49
50 // Clone returns a deep copy. The global-only pin in loadForRoot must capture
51 // the pre-project-merge value, but TOML decoding mutates existing slice
52 // backing arrays in place — a shallow struct copy would alias Hosts (and each
53 // host's Forwards) and let a project reasonix.toml overwrite the "restored"
54 // global entries.
55 func (r RemoteConfig) Clone() RemoteConfig {
56 out := r
57 if r.Hosts != nil {
58 out.Hosts = make([]RemoteHostEntry, len(r.Hosts))
59 for i, h := range r.Hosts {
60 h.Forwards = append([]RemoteForwardEntry(nil), h.Forwards...)
61 out.Hosts[i] = h
62 }
63 }
64 return out
65 }
66
67 // ServeInstallMode returns the normalized install strategy, defaulting to auto.
68 func (e RemoteHostEntry) ServeInstallMode() string {
69 m := strings.ToLower(strings.TrimSpace(e.ServeInstall))
70 if m == "" {
71 return "auto"
72 }
73 return m
74 }
75
76 // PortOrDefault returns the configured port, defaulting to 22.
77 func (e RemoteHostEntry) PortOrDefault() int {
78 if e.Port > 0 {
79 return e.Port
80 }
81 return 22
82 }
83
84 func validateRemoteHost(e RemoteHostEntry) error {
85 if strings.TrimSpace(e.Name) == "" {
86 return fmt.Errorf("remote host: name is required")
87 }
88 if strings.ContainsAny(e.Name, " \t/:@") {
89 return fmt.Errorf("remote host %q: name must not contain spaces, '/', ':' or '@'", e.Name)
90 }
91 if strings.TrimSpace(e.Host) == "" {
92 return fmt.Errorf("remote host %q: host is required", e.Name)
93 }
94 if e.Port < 0 || e.Port > 65535 {
95 return fmt.Errorf("remote host %q: port %d out of range", e.Name, e.Port)
96 }
97 switch e.ServeInstallMode() {
98 case "auto", "npm", "upload", "never":
99 default:
100 return fmt.Errorf("remote host %q: serve_install must be one of auto|npm|upload|never", e.Name)
101 }
102 seenBinds := map[string]bool{}
103 for _, f := range e.Forwards {
104 kind := strings.ToLower(strings.TrimSpace(f.Type))
105 switch kind {
106 case "local", "remote":
107 default:
108 return fmt.Errorf("remote host %q: forward type must be \"local\" or \"remote\"", e.Name)
109 }
110 if strings.TrimSpace(f.Bind) == "" || strings.TrimSpace(f.Target) == "" {
111 return fmt.Errorf("remote host %q: forward needs both bind and target", e.Name)
112 }
113 bind, err := validateRemoteForwardAddress(f.Bind, true)
114 if err != nil {
115 return fmt.Errorf("remote host %q: invalid forward bind %q: %w", e.Name, f.Bind, err)
116 }
117 if _, err := validateRemoteForwardAddress(f.Target, false); err != nil {
118 return fmt.Errorf("remote host %q: invalid forward target %q: %w", e.Name, f.Target, err)
119 }
120 key := kind + "\x00" + bind
121 if seenBinds[key] {
122 return fmt.Errorf("remote host %q: duplicate %s forward bind %q", e.Name, kind, f.Bind)
123 }
124 seenBinds[key] = true
125 }
126 return nil
127 }
128
129 func validateRemoteForwardAddress(addr string, bind bool) (string, error) {
130 addr = strings.TrimSpace(addr)
131 if bind && !strings.Contains(addr, ":") {
132 addr = net.JoinHostPort("127.0.0.1", addr)
133 }
134 host, portText, err := net.SplitHostPort(addr)
135 if err != nil {
136 return "", err
137 }
138 if !bind && strings.TrimSpace(host) == "" {
139 return "", fmt.Errorf("host is required")
140 }
141 port, err := strconv.Atoi(portText)
142 if err != nil || port < 0 || port > 65535 || (!bind && port == 0) {
143 return "", fmt.Errorf("port out of range")
144 }
145 return net.JoinHostPort(host, strconv.Itoa(port)), nil
146 }
147
148 // RemoteHost looks up a configured host by name.
149 func (c *Config) RemoteHost(name string) (RemoteHostEntry, bool) {
150 for _, h := range c.Remote.Hosts {
151 if h.Name == name {
152 return h, true
153 }
154 }
155 return RemoteHostEntry{}, false
156 }
157
158 // UpsertRemoteHost adds e, or replaces the host with the same name
159 // (preserving position). Mirrors UpsertPlugin.
160 func (c *Config) UpsertRemoteHost(e RemoteHostEntry) error {
161 if err := validateRemoteHost(e); err != nil {
162 return err
163 }
164 for i := range c.Remote.Hosts {
165 if c.Remote.Hosts[i].Name == e.Name {
166 c.Remote.Hosts[i] = e
167 return nil
168 }
169 }
170 c.Remote.Hosts = append(c.Remote.Hosts, e)
171 return nil
172 }
173
174 // RemoveRemoteHost deletes the named host, reporting whether it was present.
175 func (c *Config) RemoveRemoteHost(name string) bool {
176 for i := range c.Remote.Hosts {
177 if c.Remote.Hosts[i].Name == name {
178 c.Remote.Hosts = append(c.Remote.Hosts[:i], c.Remote.Hosts[i+1:]...)
179 return true
180 }
181 }
182 return false
183 }
184
184 lines GO