返回 DeepSeek-Reasonix
remote.go
根目录 / internal / config / remote.go
1 package config
2
3 import (
4 "fmt"
5 "net"
6 "path"
7 "strconv"
8 "strings"
9 )
10
11 // RemoteConfig is the [remote] section: SSH hosts the remote module may
12 // connect to, and their default forwards/workspaces. Like [secrets] it is a
13 // user-global security control — LoadForRoot pins it back to the user config
14 // after the project merge so a cloned repo's reasonix.toml can never inject
15 // hosts, jump chains, or forwards.
16 type RemoteConfig struct {
17 // ImportSSHConfig surfaces ~/.ssh/config aliases in `reasonix remote import`.
18 ImportSSHConfig bool `toml:"import_ssh_config"`
19 Hosts []RemoteHostEntry `toml:"hosts"`
20 Projects []RemoteProjectEntry `toml:"projects"`
21 }
22
23 // RemoteHostEntry describes one SSH target. Secrets follow the provider
24 // idiom: the entry names credential env vars (passphrase_env/password_env);
25 // values live in Reasonix's global .env, never in TOML. identity_file is a
26 // path — private key material itself is never stored by Reasonix.
27 type RemoteHostEntry struct {
28 Name string `toml:"name"`
29 Host string `toml:"host"`
30 Port int `toml:"port"` // 0 => 22 (or ssh_config value)
31 User string `toml:"user"`
32 IdentityFile string `toml:"identity_file"`
33 PassphraseEnv string `toml:"passphrase_env"`
34 PasswordEnv string `toml:"password_env"`
35 ProxyJump string `toml:"proxy_jump"` // OpenSSH ProxyJump syntax, comma-separated chain
36 Workspace string `toml:"workspace"` // default remote workspace dir
37 ServeInstall string `toml:"serve_install"` // remote CLI: auto|npm|upload|never
38 CredentialMode string `toml:"credential_mode"` // model-call credentials: ""|remote (on the host) | local-proxy (desktop holds the key, calls tunnel back)
39 UseSSHConfig bool `toml:"use_ssh_config"` // layer ~/.ssh/config values under unset fields
40 Forwards []RemoteForwardEntry `toml:"forwards"`
41 }
42
43 // RemoteForwardEntry is a persisted port-forward rule applied on connect.
44 type RemoteForwardEntry struct {
45 Type string `toml:"type"` // "local" (-L) | "remote" (-R)
46 Bind string `toml:"bind"` // "127.0.0.1:8080" or bare port => 127.0.0.1:<port>
47 Target string `toml:"target"` // host:port on the other side
48 }
49
50 // RemoteProjectEntry pins one remote workspace so it shows in the project
51 // tree. It references a configured host by name.
52 type RemoteProjectEntry struct {
53 HostID string `toml:"host_id"`
54 Workspace string `toml:"workspace"`
55 Title string `toml:"title,omitempty"`
56 SessionOrganization string `toml:"session_organization,omitempty"`
57 }
58
59 // RemoteServeInstallModes are the accepted serve_install values.
60 var RemoteServeInstallModes = []string{"auto", "npm", "upload", "never"}
61
62 // Clone returns a deep copy. The global-only pin in loadForRoot must capture
63 // the pre-project-merge value, but TOML decoding mutates existing slice
64 // backing arrays in place — a shallow struct copy would alias Hosts (and each
65 // host's Forwards) and let a project reasonix.toml overwrite the "restored"
66 // global entries.
67 func (r RemoteConfig) Clone() RemoteConfig {
68 out := r
69 if r.Hosts != nil {
70 out.Hosts = make([]RemoteHostEntry, len(r.Hosts))
71 for i, h := range r.Hosts {
72 h.Forwards = append([]RemoteForwardEntry(nil), h.Forwards...)
73 out.Hosts[i] = h
74 }
75 }
76 if r.Projects != nil {
77 out.Projects = append([]RemoteProjectEntry(nil), r.Projects...)
78 }
79 return out
80 }
81
82 // ServeInstallMode returns the normalized install strategy, defaulting to auto.
83 func (e RemoteHostEntry) ServeInstallMode() string {
84 m := strings.ToLower(strings.TrimSpace(e.ServeInstall))
85 if m == "" {
86 return "auto"
87 }
88 return m
89 }
90
91 // CredentialProxyEnabled reports whether the host routes model-call
92 // credentials through the desktop's reverse-tunnel proxy instead of keeping
93 // provider keys on the remote host.
94 func (e RemoteHostEntry) CredentialProxyEnabled() bool {
95 return strings.EqualFold(strings.TrimSpace(e.CredentialMode), "local-proxy")
96 }
97
98 // PortOrDefault returns the configured port, defaulting to 22.
99 func (e RemoteHostEntry) PortOrDefault() int {
100 if e.Port > 0 {
101 return e.Port
102 }
103 return 22
104 }
105
106 func validateRemoteHost(e RemoteHostEntry) error {
107 if strings.TrimSpace(e.Name) == "" {
108 return fmt.Errorf("remote host: name is required")
109 }
110 if strings.ContainsAny(e.Name, " \t/:@") {
111 return fmt.Errorf("remote host %q: name must not contain spaces, '/', ':' or '@'", e.Name)
112 }
113 if strings.TrimSpace(e.Host) == "" {
114 return fmt.Errorf("remote host %q: host is required", e.Name)
115 }
116 switch strings.ToLower(strings.TrimSpace(e.CredentialMode)) {
117 case "", "remote", "local-proxy":
118 default:
119 return fmt.Errorf("remote host %q: credential_mode must be remote or local-proxy", e.Name)
120 }
121 if e.Port < 0 || e.Port > 65535 {
122 return fmt.Errorf("remote host %q: port %d out of range", e.Name, e.Port)
123 }
124 switch e.ServeInstallMode() {
125 case "auto", "npm", "upload", "never":
126 default:
127 return fmt.Errorf("remote host %q: serve_install must be one of auto|npm|upload|never", e.Name)
128 }
129 seenBinds := map[string]bool{}
130 for _, f := range e.Forwards {
131 kind := strings.ToLower(strings.TrimSpace(f.Type))
132 switch kind {
133 case "local", "remote":
134 default:
135 return fmt.Errorf("remote host %q: forward type must be \"local\" or \"remote\"", e.Name)
136 }
137 if strings.TrimSpace(f.Bind) == "" || strings.TrimSpace(f.Target) == "" {
138 return fmt.Errorf("remote host %q: forward needs both bind and target", e.Name)
139 }
140 bind, err := validateRemoteForwardAddress(f.Bind, true)
141 if err != nil {
142 return fmt.Errorf("remote host %q: invalid forward bind %q: %w", e.Name, f.Bind, err)
143 }
144 if _, err := validateRemoteForwardAddress(f.Target, false); err != nil {
145 return fmt.Errorf("remote host %q: invalid forward target %q: %w", e.Name, f.Target, err)
146 }
147 key := kind + "\x00" + bind
148 if seenBinds[key] {
149 return fmt.Errorf("remote host %q: duplicate %s forward bind %q", e.Name, kind, f.Bind)
150 }
151 seenBinds[key] = true
152 }
153 return nil
154 }
155
156 func validateRemoteForwardAddress(addr string, bind bool) (string, error) {
157 addr = strings.TrimSpace(addr)
158 if bind && !strings.Contains(addr, ":") {
159 addr = net.JoinHostPort("127.0.0.1", addr)
160 }
161 host, portText, err := net.SplitHostPort(addr)
162 if err != nil {
163 return "", err
164 }
165 if !bind && strings.TrimSpace(host) == "" {
166 return "", fmt.Errorf("host is required")
167 }
168 port, err := strconv.Atoi(portText)
169 if err != nil || port < 0 || port > 65535 || (!bind && port == 0) {
170 return "", fmt.Errorf("port out of range")
171 }
172 return net.JoinHostPort(host, strconv.Itoa(port)), nil
173 }
174
175 // RemoteHost looks up a configured host by name.
176 func (c *Config) RemoteHost(name string) (RemoteHostEntry, bool) {
177 for _, h := range c.Remote.Hosts {
178 if h.Name == name {
179 return h, true
180 }
181 }
182 return RemoteHostEntry{}, false
183 }
184
185 // UpsertRemoteHost adds e, or replaces the host with the same name
186 // (preserving position). Mirrors UpsertPlugin.
187 func (c *Config) UpsertRemoteHost(e RemoteHostEntry) error {
188 if err := validateRemoteHost(e); err != nil {
189 return err
190 }
191 for i := range c.Remote.Hosts {
192 if c.Remote.Hosts[i].Name == e.Name {
193 c.Remote.Hosts[i] = e
194 return nil
195 }
196 }
197 c.Remote.Hosts = append(c.Remote.Hosts, e)
198 return nil
199 }
200
201 // RemoveRemoteHost deletes the named host, reporting whether it was present.
202 func (c *Config) RemoveRemoteHost(name string) bool {
203 for i := range c.Remote.Hosts {
204 if c.Remote.Hosts[i].Name == name {
205 c.Remote.Hosts = append(c.Remote.Hosts[:i], c.Remote.Hosts[i+1:]...)
206 projects := c.Remote.Projects[:0]
207 for _, project := range c.Remote.Projects {
208 if project.HostID != name {
209 projects = append(projects, project)
210 }
211 }
212 c.Remote.Projects = projects
213 return true
214 }
215 }
216 return false
217 }
218
219 func normalizeRemoteWorkspace(workspace string) string {
220 workspace = strings.TrimSpace(workspace)
221 if workspace == "" {
222 return ""
223 }
224 return path.Clean(workspace)
225 }
226
227 // RemoteProject looks up a pinned remote workspace by host and normalized
228 // POSIX path. Remote targets are currently Linux/macOS, so slash semantics are
229 // stable even when the desktop itself runs on another platform.
230 func (c *Config) RemoteProject(hostID, workspace string) (RemoteProjectEntry, bool) {
231 hostID = strings.TrimSpace(hostID)
232 workspace = normalizeRemoteWorkspace(workspace)
233 for _, project := range c.Remote.Projects {
234 if project.HostID == hostID && normalizeRemoteWorkspace(project.Workspace) == workspace {
235 return project, true
236 }
237 }
238 return RemoteProjectEntry{}, false
239 }
240
241 // UpsertRemoteProject adds e, or replaces the entry with the same host and
242 // normalized workspace while preserving its position.
243 func (c *Config) UpsertRemoteProject(e RemoteProjectEntry) error {
244 e.HostID = strings.TrimSpace(e.HostID)
245 e.Workspace = normalizeRemoteWorkspace(e.Workspace)
246 e.Title = strings.TrimSpace(e.Title)
247 if e.HostID == "" {
248 return fmt.Errorf("remote project: host is required")
249 }
250 if e.Workspace == "" {
251 return fmt.Errorf("remote project %q: workspace is required", e.HostID)
252 }
253 if _, ok := c.RemoteHost(e.HostID); !ok {
254 return fmt.Errorf("remote project: unknown remote host %q", e.HostID)
255 }
256 for i := range c.Remote.Projects {
257 project := &c.Remote.Projects[i]
258 if project.HostID == e.HostID && normalizeRemoteWorkspace(project.Workspace) == e.Workspace {
259 *project = e
260 return nil
261 }
262 }
263 c.Remote.Projects = append(c.Remote.Projects, e)
264 return nil
265 }
266
267 // RemoveRemoteProject deletes the pinned workspace, reporting whether it was
268 // present.
269 func (c *Config) RemoveRemoteProject(hostID, workspace string) bool {
270 hostID = strings.TrimSpace(hostID)
271 workspace = normalizeRemoteWorkspace(workspace)
272 for i := range c.Remote.Projects {
273 project := c.Remote.Projects[i]
274 if project.HostID == hostID && normalizeRemoteWorkspace(project.Workspace) == workspace {
275 c.Remote.Projects = append(c.Remote.Projects[:i], c.Remote.Projects[i+1:]...)
276 return true
277 }
278 }
279 return false
280 }
281
281 lines GO