返回 DeepSeek-Reasonix
remote_hosts.go
根目录 / desktop / remote_hosts.go
1 package main
2
3 import (
4 "bytes"
5 "crypto/rand"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "net"
12 "os"
13 "path/filepath"
14 "regexp"
15 "sort"
16 "strconv"
17 "strings"
18 "sync"
19 "unicode/utf8"
20
21 "reasonix/internal/fileutil"
22 )
23
24 const (
25 remoteHostStoreVersion = 2
26 remoteHostStoreVersion1 = 1
27 remoteHostStoreMaxBytes = 1 << 20
28 remoteHostStoreMaxHosts = 256
29 defaultRemoteSSHPort = 22
30 )
31
32 type RemoteHostConnectionMode string
33
34 const (
35 RemoteHostConnectionDirect RemoteHostConnectionMode = "direct"
36 RemoteHostConnectionConfig RemoteHostConnectionMode = "config"
37 )
38
39 var (
40 // ErrRemoteHostStoreCorrupt means the on-disk store was not accepted. A
41 // caller must not treat this as an empty store and overwrite it: doing so
42 // could silently discard the only persisted lease needed for a reconnect.
43 ErrRemoteHostStoreCorrupt = errors.New("remote host store is corrupt")
44 // ErrRemoteHostStoreUnsafe means the path is not a private regular file.
45 ErrRemoteHostStoreUnsafe = errors.New("remote host store is unsafe")
46
47 remoteHostAliasPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$`)
48 remoteSSHUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$`)
49 remoteDNSLabelPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
50 remoteNumericHostPattern = regexp.MustCompile(`^[0-9.]+$`)
51 remoteHostIDPattern = regexp.MustCompile(`^host_[0-9a-f]{64}$`)
52 remoteClientIDPattern = regexp.MustCompile(`^desktop_[0-9a-f]{64}$`)
53 remoteHostPathLocks sync.Map // canonical path -> *sync.Mutex
54 )
55
56 // RemoteHostEntry is deliberately a secret-free record. SSH credentials,
57 // private-key passphrases, passwords and AskPass material never belong here.
58 // Direct entries carry only a validated username, Host and port; advanced
59 // entries retain an OpenSSH config alias.
60 type RemoteHostEntry struct {
61 ID string `json:"id"`
62 Mode RemoteHostConnectionMode `json:"mode"`
63 Destination string `json:"destination,omitempty"`
64 Port int `json:"port,omitempty"`
65 Alias string `json:"alias,omitempty"`
66 Label string `json:"label"`
67 SSHConfigPath string `json:"sshConfigPath,omitempty"`
68 ClientInstanceID string `json:"clientInstanceId"`
69 ResumeLeaseID string `json:"resumeLeaseId,omitempty"`
70 LayoutRef string `json:"layoutRef,omitempty"`
71 }
72
73 type remoteHostStoreDocument struct {
74 Version int `json:"version"`
75 Hosts []RemoteHostEntry `json:"hosts"`
76 }
77
78 // RemoteHostStore serializes all in-process access to one canonical path. The
79 // write itself is a sibling-temp + fsync + atomic replace, so readers observe
80 // either the old complete document or the new complete document.
81 type RemoteHostStore struct {
82 path string
83 mu *sync.Mutex
84 }
85
86 func NewRemoteHostStore(path string) (*RemoteHostStore, error) {
87 if strings.TrimSpace(path) == "" || strings.IndexByte(path, 0) >= 0 {
88 return nil, fmt.Errorf("remote host store path is required")
89 }
90 absolute, err := filepath.Abs(path)
91 if err != nil {
92 return nil, fmt.Errorf("resolve remote host store path: %w", err)
93 }
94 absolute = filepath.Clean(absolute)
95 lock, _ := remoteHostPathLocks.LoadOrStore(absolute, &sync.Mutex{})
96 return &RemoteHostStore{path: absolute, mu: lock.(*sync.Mutex)}, nil
97 }
98
99 func (s *RemoteHostStore) Path() string { return s.path }
100
101 // NewRemoteHostEntry creates the stable 256-bit client identity owned by one
102 // saved Host entry. Reconnects reuse it; different Host entries never do.
103 func NewRemoteHostEntry(alias, label string) (RemoteHostEntry, error) {
104 return newRemoteHostEntry(RemoteHostEntry{Mode: RemoteHostConnectionConfig, Alias: alias, Label: label})
105 }
106
107 func NewRemoteDirectHostEntry(destination string, port int, label string) (RemoteHostEntry, error) {
108 target, err := ParseRemoteSSHDirectDestination(destination)
109 if err != nil {
110 return RemoteHostEntry{}, err
111 }
112 if err := ValidateRemoteSSHPort(port); err != nil {
113 return RemoteHostEntry{}, err
114 }
115 return newRemoteHostEntry(RemoteHostEntry{
116 Mode: RemoteHostConnectionDirect, Destination: target.Destination(), Port: port, Label: label,
117 })
118 }
119
120 func newRemoteHostEntry(entry RemoteHostEntry) (RemoteHostEntry, error) {
121 var entryEntropy, clientEntropy [32]byte
122 if _, err := rand.Read(entryEntropy[:]); err != nil {
123 return RemoteHostEntry{}, fmt.Errorf("generate remote Host identity: %w", err)
124 }
125 if _, err := rand.Read(clientEntropy[:]); err != nil {
126 return RemoteHostEntry{}, fmt.Errorf("generate remote client identity: %w", err)
127 }
128 entry.ID = "host_" + hex.EncodeToString(entryEntropy[:])
129 entry.ClientInstanceID = "desktop_" + hex.EncodeToString(clientEntropy[:])
130 if err := validateRemoteHostEntry(entry); err != nil {
131 return RemoteHostEntry{}, err
132 }
133 return entry, nil
134 }
135
136 type RemoteSSHDirectTarget struct {
137 Username string
138 Host string
139 }
140
141 func (t RemoteSSHDirectTarget) Destination() string {
142 host := t.Host
143 if strings.Contains(host, ":") {
144 host = "[" + host + "]"
145 }
146 return t.Username + "@" + host
147 }
148
149 // ParseRemoteSSHDirectDestination validates the user-facing username@host
150 // form and returns canonical argv values. IPv6 literals must be bracketed so
151 // the separator remains unambiguous.
152 func ParseRemoteSSHDirectDestination(destination string) (RemoteSSHDirectTarget, error) {
153 if !utf8.ValidString(destination) || len(destination) > 384 || strings.TrimSpace(destination) != destination || strings.Count(destination, "@") != 1 {
154 return RemoteSSHDirectTarget{}, errors.New("remote SSH destination must be username@host")
155 }
156 username, host, _ := strings.Cut(destination, "@")
157 if !remoteSSHUsernamePattern.MatchString(username) || strings.HasPrefix(username, "-") {
158 return RemoteSSHDirectTarget{}, errors.New("remote SSH username is invalid")
159 }
160 canonicalHost, err := canonicalRemoteSSHHost(host)
161 if err != nil {
162 return RemoteSSHDirectTarget{}, err
163 }
164 return RemoteSSHDirectTarget{Username: username, Host: canonicalHost}, nil
165 }
166
167 func canonicalRemoteSSHHost(host string) (string, error) {
168 if host == "" || strings.HasPrefix(host, "-") {
169 return "", errors.New("remote SSH host is invalid")
170 }
171 if strings.HasPrefix(host, "[") || strings.HasSuffix(host, "]") {
172 if len(host) < 3 || host[0] != '[' || host[len(host)-1] != ']' {
173 return "", errors.New("remote SSH IPv6 host must use [address] form")
174 }
175 ip := net.ParseIP(host[1 : len(host)-1])
176 if ip == nil || ip.To4() != nil {
177 return "", errors.New("remote SSH bracketed host must be an IPv6 address")
178 }
179 return ip.String(), nil
180 }
181 if ip := net.ParseIP(host); ip != nil {
182 if ip.To4() == nil {
183 return "", errors.New("remote SSH IPv6 host must use [address] form")
184 }
185 return ip.String(), nil
186 }
187 if strings.Contains(host, ":") {
188 return "", errors.New("remote SSH IPv6 host must use [address] form")
189 }
190 if len(host) > 253 || remoteNumericHostPattern.MatchString(host) {
191 return "", errors.New("remote SSH host is invalid")
192 }
193 trimmed := strings.TrimSuffix(host, ".")
194 if trimmed == "" || len(trimmed) > 253 {
195 return "", errors.New("remote SSH host is invalid")
196 }
197 for _, label := range strings.Split(trimmed, ".") {
198 if !remoteDNSLabelPattern.MatchString(label) {
199 return "", errors.New("remote SSH host is invalid")
200 }
201 }
202 return strings.ToLower(trimmed), nil
203 }
204
205 func ValidateRemoteSSHPort(port int) error {
206 if port < 1 || port > 65535 {
207 return errors.New("remote SSH port must be between 1 and 65535")
208 }
209 return nil
210 }
211
212 func ValidateRemoteHostAlias(alias string) error {
213 if !remoteHostAliasPattern.MatchString(alias) {
214 return fmt.Errorf("invalid SSH Host alias %q", alias)
215 }
216 return nil
217 }
218
219 func ValidateRemoteHostEntryID(entryID string) error {
220 if !remoteHostIDPattern.MatchString(entryID) {
221 return errors.New("invalid remote Host entry id")
222 }
223 return nil
224 }
225
226 func (s *RemoteHostStore) Load() ([]RemoteHostEntry, error) {
227 s.mu.Lock()
228 defer s.mu.Unlock()
229 return s.loadLocked()
230 }
231
232 func (s *RemoteHostStore) Upsert(entry RemoteHostEntry) error {
233 if err := validateRemoteHostEntry(entry); err != nil {
234 return err
235 }
236 s.mu.Lock()
237 defer s.mu.Unlock()
238 hosts, err := s.loadLocked()
239 if err != nil {
240 return err
241 }
242 replaced := false
243 for i := range hosts {
244 if hosts[i].ID == entry.ID {
245 hosts[i] = entry
246 replaced = true
247 break
248 }
249 }
250 if !replaced {
251 hosts = append(hosts, entry)
252 }
253 return s.saveLocked(hosts)
254 }
255
256 func (s *RemoteHostStore) Delete(entryID string) error {
257 if err := ValidateRemoteHostEntryID(entryID); err != nil {
258 return err
259 }
260 s.mu.Lock()
261 defer s.mu.Unlock()
262 hosts, err := s.loadLocked()
263 if err != nil {
264 return err
265 }
266 filtered := hosts[:0]
267 for _, host := range hosts {
268 if host.ID != entryID {
269 filtered = append(filtered, host)
270 }
271 }
272 if len(filtered) == len(hosts) {
273 return nil
274 }
275 return s.saveLocked(filtered)
276 }
277
278 func (s *RemoteHostStore) UpdateResumeLease(entryID, leaseID string) error {
279 return s.update(entryID, func(host *RemoteHostEntry) { host.ResumeLeaseID = leaseID })
280 }
281
282 func (s *RemoteHostStore) UpdateLayoutRef(entryID, layoutRef string) error {
283 return s.update(entryID, func(host *RemoteHostEntry) { host.LayoutRef = layoutRef })
284 }
285
286 func (s *RemoteHostStore) update(entryID string, mutate func(*RemoteHostEntry)) error {
287 if err := ValidateRemoteHostEntryID(entryID); err != nil {
288 return err
289 }
290 s.mu.Lock()
291 defer s.mu.Unlock()
292 hosts, err := s.loadLocked()
293 if err != nil {
294 return err
295 }
296 for i := range hosts {
297 if hosts[i].ID != entryID {
298 continue
299 }
300 mutate(&hosts[i])
301 if err := validateRemoteHostEntry(hosts[i]); err != nil {
302 return err
303 }
304 return s.saveLocked(hosts)
305 }
306 return fmt.Errorf("remote Host entry %q is not saved", entryID)
307 }
308
309 func (s *RemoteHostStore) Get(entryID string) (RemoteHostEntry, bool, error) {
310 if err := ValidateRemoteHostEntryID(entryID); err != nil {
311 return RemoteHostEntry{}, false, err
312 }
313 s.mu.Lock()
314 defer s.mu.Unlock()
315 hosts, err := s.loadLocked()
316 if err != nil {
317 return RemoteHostEntry{}, false, err
318 }
319 for _, host := range hosts {
320 if host.ID == entryID {
321 return host, true, nil
322 }
323 }
324 return RemoteHostEntry{}, false, nil
325 }
326
327 func (s *RemoteHostStore) loadLocked() ([]RemoteHostEntry, error) {
328 file, info, err := openRemoteHostStoreFile(s.path)
329 if errors.Is(err, os.ErrNotExist) {
330 return []RemoteHostEntry{}, nil
331 }
332 if err != nil {
333 return nil, fmt.Errorf("open remote host store: %w", err)
334 }
335 defer file.Close()
336 if !info.Mode().IsRegular() {
337 return nil, fmt.Errorf("%w: path is not a regular file", ErrRemoteHostStoreUnsafe)
338 }
339 if err := validateRemoteHostStorePermissions(info); err != nil {
340 return nil, err
341 }
342 if info.Size() < 0 || info.Size() > remoteHostStoreMaxBytes {
343 return nil, fmt.Errorf("%w: document exceeds %d bytes", ErrRemoteHostStoreCorrupt, remoteHostStoreMaxBytes)
344 }
345 raw, err := io.ReadAll(io.LimitReader(file, remoteHostStoreMaxBytes+1))
346 if err != nil {
347 return nil, fmt.Errorf("read remote host store: %w", err)
348 }
349 if len(raw) > remoteHostStoreMaxBytes {
350 return nil, fmt.Errorf("%w: document exceeds %d bytes", ErrRemoteHostStoreCorrupt, remoteHostStoreMaxBytes)
351 }
352 decoder := json.NewDecoder(bytes.NewReader(raw))
353 decoder.DisallowUnknownFields()
354 var document remoteHostStoreDocument
355 if err := decoder.Decode(&document); err != nil {
356 return nil, fmt.Errorf("%w: %v", ErrRemoteHostStoreCorrupt, err)
357 }
358 if err := requireJSONEOF(decoder); err != nil {
359 return nil, fmt.Errorf("%w: %v", ErrRemoteHostStoreCorrupt, err)
360 }
361 if document.Version != remoteHostStoreVersion && document.Version != remoteHostStoreVersion1 {
362 return nil, fmt.Errorf("%w: unsupported version %d", ErrRemoteHostStoreCorrupt, document.Version)
363 }
364 if document.Version == remoteHostStoreVersion1 {
365 for index := range document.Hosts {
366 document.Hosts[index].Mode = RemoteHostConnectionConfig
367 }
368 }
369 if len(document.Hosts) > remoteHostStoreMaxHosts {
370 return nil, fmt.Errorf("%w: too many Host entries", ErrRemoteHostStoreCorrupt)
371 }
372 seenIDs := make(map[string]struct{}, len(document.Hosts))
373 seenConnections := make(map[string]struct{}, len(document.Hosts))
374 for _, host := range document.Hosts {
375 if err := validateRemoteHostEntry(host); err != nil {
376 return nil, fmt.Errorf("%w: %v", ErrRemoteHostStoreCorrupt, err)
377 }
378 if _, exists := seenIDs[host.ID]; exists {
379 return nil, fmt.Errorf("%w: duplicate Host entry id %q", ErrRemoteHostStoreCorrupt, host.ID)
380 }
381 seenIDs[host.ID] = struct{}{}
382 connectionKey := remoteHostConnectionKey(host)
383 if _, exists := seenConnections[connectionKey]; exists {
384 return nil, fmt.Errorf("%w: duplicate SSH Host entry", ErrRemoteHostStoreCorrupt)
385 }
386 seenConnections[connectionKey] = struct{}{}
387 }
388 return append([]RemoteHostEntry(nil), document.Hosts...), nil
389 }
390
391 func (s *RemoteHostStore) saveLocked(hosts []RemoteHostEntry) error {
392 if len(hosts) > remoteHostStoreMaxHosts {
393 return fmt.Errorf("too many remote Host entries")
394 }
395 copyOfHosts := append([]RemoteHostEntry(nil), hosts...)
396 seenIDs := make(map[string]struct{}, len(copyOfHosts))
397 seenConnections := make(map[string]struct{}, len(copyOfHosts))
398 for _, host := range copyOfHosts {
399 if err := validateRemoteHostEntry(host); err != nil {
400 return err
401 }
402 if _, exists := seenIDs[host.ID]; exists {
403 return fmt.Errorf("duplicate remote Host entry id %q", host.ID)
404 }
405 seenIDs[host.ID] = struct{}{}
406 connectionKey := remoteHostConnectionKey(host)
407 if _, exists := seenConnections[connectionKey]; exists {
408 return fmt.Errorf("duplicate SSH Host entry %q", remoteHostDisplayConnection(host))
409 }
410 seenConnections[connectionKey] = struct{}{}
411 }
412 sort.Slice(copyOfHosts, func(i, j int) bool { return copyOfHosts[i].ID < copyOfHosts[j].ID })
413 raw, err := json.MarshalIndent(remoteHostStoreDocument{Version: remoteHostStoreVersion, Hosts: copyOfHosts}, "", " ")
414 if err != nil {
415 return fmt.Errorf("encode remote host store: %w", err)
416 }
417 raw = append(raw, '\n')
418 if len(raw) > remoteHostStoreMaxBytes {
419 return fmt.Errorf("remote host store exceeds %d bytes", remoteHostStoreMaxBytes)
420 }
421 if err := fileutil.AtomicWriteFile(s.path, raw, 0o600); err != nil {
422 return fmt.Errorf("write remote host store: %w", err)
423 }
424 return nil
425 }
426
427 func validateRemoteHostEntry(host RemoteHostEntry) error {
428 if err := ValidateRemoteHostEntryID(host.ID); err != nil {
429 return err
430 }
431 switch host.Mode {
432 case RemoteHostConnectionDirect:
433 target, err := ParseRemoteSSHDirectDestination(host.Destination)
434 if err != nil {
435 return err
436 }
437 if target.Destination() != host.Destination {
438 return errors.New("remote SSH destination must be canonical")
439 }
440 if err := ValidateRemoteSSHPort(host.Port); err != nil {
441 return err
442 }
443 if host.Alias != "" || host.SSHConfigPath != "" {
444 return errors.New("direct remote Host must not contain OpenSSH config fields")
445 }
446 case RemoteHostConnectionConfig:
447 if err := ValidateRemoteHostAlias(host.Alias); err != nil {
448 return err
449 }
450 if err := validateRemoteSSHConfigPath(host.SSHConfigPath); err != nil {
451 return err
452 }
453 if host.Destination != "" || host.Port != 0 {
454 return errors.New("config remote Host must not contain direct connection fields")
455 }
456 default:
457 return errors.New("remote Host connection mode is invalid")
458 }
459 if err := validateRemoteHostText("label", host.Label, 256, false); err != nil {
460 return err
461 }
462 if !remoteClientIDPattern.MatchString(host.ClientInstanceID) {
463 return errors.New("remote Host clientInstanceId must be a generated 256-bit identity")
464 }
465 if err := validateRemoteHostText("resumeLeaseId", host.ResumeLeaseID, 512, true); err != nil {
466 return err
467 }
468 if err := validateRemoteHostText("layoutRef", host.LayoutRef, 512, true); err != nil {
469 return err
470 }
471 return nil
472 }
473
474 func validateRemoteSSHConfigPath(path string) error {
475 if path == "" {
476 return nil
477 }
478 if !utf8.ValidString(path) || strings.IndexByte(path, 0) >= 0 {
479 return errors.New("remote Host sshConfigPath is invalid")
480 }
481 for _, r := range path {
482 if r < 0x20 || r == 0x7f {
483 return errors.New("remote Host sshConfigPath contains a control character")
484 }
485 }
486 if !filepath.IsAbs(path) || filepath.Clean(path) != path {
487 return errors.New("remote Host sshConfigPath must be an absolute clean path")
488 }
489 return nil
490 }
491
492 func remoteHostConnectionKey(host RemoteHostEntry) string {
493 switch host.Mode {
494 case RemoteHostConnectionDirect:
495 return string(host.Mode) + "\x00" + host.Destination + "\x00" + strconv.Itoa(host.Port)
496 case RemoteHostConnectionConfig:
497 return string(host.Mode) + "\x00" + host.SSHConfigPath + "\x00" + host.Alias
498 default:
499 return string(host.Mode)
500 }
501 }
502
503 func remoteHostDisplayConnection(host RemoteHostEntry) string {
504 if host.Mode == RemoteHostConnectionDirect {
505 target, err := ParseRemoteSSHDirectDestination(host.Destination)
506 if err == nil {
507 return target.Username + "@" + net.JoinHostPort(target.Host, strconv.Itoa(host.Port))
508 }
509 return host.Destination
510 }
511 return host.Alias
512 }
513
514 func validateRemoteHostText(field, value string, maxBytes int, allowEmpty bool) error {
515 if value == "" {
516 if allowEmpty {
517 return nil
518 }
519 return fmt.Errorf("remote Host %s is required", field)
520 }
521 if len(value) > maxBytes || !utf8.ValidString(value) {
522 return fmt.Errorf("remote Host %s is invalid", field)
523 }
524 if strings.TrimSpace(value) != value {
525 return fmt.Errorf("remote Host %s must not have surrounding whitespace", field)
526 }
527 for _, r := range value {
528 if r < 0x20 || r == 0x7f {
529 return fmt.Errorf("remote Host %s contains a control character", field)
530 }
531 }
532 return nil
533 }
534
535 func requireJSONEOF(decoder *json.Decoder) error {
536 var extra any
537 if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
538 if err == nil {
539 return errors.New("multiple JSON values")
540 }
541 return err
542 }
543 return nil
544 }
545
545 lines GO