返回 DeepSeek-Reasonix
forward.go
根目录 / internal / remote / forward / forward.go
1 // Package forward manages SSH port-forward rules bound to a live connection.
2 // Local (-L) forwards keep their local listener open across reconnects so a
3 // forwarded serve URL survives an outage; remote (-R) forwards are
4 // re-registered on every re-attach because they die with the SSH connection.
5 package forward
6
7 import (
8 "errors"
9 "fmt"
10 "net"
11 "strconv"
12 "strings"
13 )
14
15 // Direction is the forward direction.
16 type Direction int
17
18 const (
19 // Local is an -L forward: listen locally, dial from the remote side.
20 Local Direction = iota
21 // Remote is an -R forward: listen on the remote side, dial locally.
22 Remote
23 )
24
25 func (d Direction) String() string {
26 if d == Remote {
27 return "remote"
28 }
29 return "local"
30 }
31
32 // Spec describes one forward rule.
33 type Spec struct {
34 Name string // stable id; derived from bind/target when empty
35 Direction Direction
36 BindAddr string // "127.0.0.1:8080"; ":0" allowed for Local (ephemeral)
37 TargetAddr string // "127.0.0.1:80"
38 }
39
40 // Validate rejects malformed bind/target addresses before a listener is
41 // registered. Target port zero is never dialable; bind port zero remains
42 // valid for an ephemeral local/remote listener.
43 func (s Spec) Validate() error {
44 if s.Direction != Local && s.Direction != Remote {
45 return fmt.Errorf("forward: invalid direction %d", s.Direction)
46 }
47 if err := validateAddress(s.BindAddr, true); err != nil {
48 return fmt.Errorf("forward: invalid bind address %q: %w", s.BindAddr, err)
49 }
50 if err := validateAddress(s.TargetAddr, false); err != nil {
51 return fmt.Errorf("forward: invalid target address %q: %w", s.TargetAddr, err)
52 }
53 return nil
54 }
55
56 func validateAddress(addr string, allowZero bool) error {
57 host, portText, err := net.SplitHostPort(strings.TrimSpace(addr))
58 if err != nil {
59 return err
60 }
61 if host == "" && !allowZero {
62 return errors.New("host is required")
63 }
64 port, err := strconv.Atoi(portText)
65 if err != nil || port < 0 || port > 65535 || (!allowZero && port == 0) {
66 return errors.New("port must be between 1 and 65535")
67 }
68 return nil
69 }
70
71 // Typed errors.
72 var (
73 ErrBindBusy = errors.New("forward: bind address in use")
74 ErrDuplicateForward = errors.New("forward: duplicate name")
75 ErrNotAttached = errors.New("forward: not attached to a connection")
76 )
77
78 // DefaultName derives a stable name for a spec that did not set one.
79 func (s Spec) DefaultName() string {
80 if s.Name != "" {
81 return s.Name
82 }
83 return fmt.Sprintf("%s:%s->%s", dirShort(s.Direction), s.BindAddr, s.TargetAddr)
84 }
85
86 func dirShort(d Direction) string {
87 if d == Remote {
88 return "R"
89 }
90 return "L"
91 }
92
93 // ParseDirection maps "local"/"-L"/"L" and "remote"/"-R"/"R".
94 func ParseDirection(s string) (Direction, error) {
95 switch strings.ToLower(strings.TrimSpace(s)) {
96 case "local", "-l", "l":
97 return Local, nil
98 case "remote", "-r", "r":
99 return Remote, nil
100 default:
101 return Local, fmt.Errorf("forward: direction must be local (-L) or remote (-R), got %q", s)
102 }
103 }
104
105 // ParseShorthand parses OpenSSH-style forward shorthands:
106 //
107 // "8080:host:80" -> bind 127.0.0.1:8080, target host:80
108 // "127.0.0.1:8080:host:80"-> bind 127.0.0.1:8080, target host:80
109 // "8080" -> bind 127.0.0.1:8080, target 127.0.0.1:8080
110 //
111 // Bare ports and unqualified binds default to loopback (127.0.0.1).
112 func ParseShorthand(dir Direction, s string) (Spec, error) {
113 s = strings.TrimSpace(s)
114 if s == "" {
115 return Spec{}, fmt.Errorf("forward: empty spec")
116 }
117 parts := strings.Split(s, ":")
118 switch len(parts) {
119 case 1:
120 p, err := parseTargetPort(parts[0])
121 if err != nil {
122 return Spec{}, err
123 }
124 addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(p))
125 return Spec{Direction: dir, BindAddr: addr, TargetAddr: addr}, nil
126 case 3:
127 // bindPort:targetHost:targetPort
128 bp, err := parsePort(parts[0])
129 if err != nil {
130 return Spec{}, err
131 }
132 tp, err := parseTargetPort(parts[2])
133 if err != nil {
134 return Spec{}, err
135 }
136 if parts[1] == "" {
137 return Spec{}, fmt.Errorf("forward: empty target host in %q", s)
138 }
139 return Spec{
140 Direction: dir,
141 BindAddr: net.JoinHostPort("127.0.0.1", strconv.Itoa(bp)),
142 TargetAddr: net.JoinHostPort(parts[1], strconv.Itoa(tp)),
143 }, nil
144 case 4:
145 // bindHost:bindPort:targetHost:targetPort
146 bp, err := parsePort(parts[1])
147 if err != nil {
148 return Spec{}, err
149 }
150 tp, err := parseTargetPort(parts[3])
151 if err != nil {
152 return Spec{}, err
153 }
154 if parts[2] == "" {
155 return Spec{}, fmt.Errorf("forward: empty target host in %q", s)
156 }
157 bindHost := parts[0]
158 if bindHost == "" {
159 bindHost = "127.0.0.1"
160 }
161 return Spec{
162 Direction: dir,
163 BindAddr: net.JoinHostPort(bindHost, strconv.Itoa(bp)),
164 TargetAddr: net.JoinHostPort(parts[2], strconv.Itoa(tp)),
165 }, nil
166 default:
167 return Spec{}, fmt.Errorf("forward: cannot parse spec %q", s)
168 }
169 }
170
171 // NonLoopbackBind reports whether the spec binds a non-loopback address, which
172 // callers surface as a warning (the forward becomes reachable off-machine).
173 func (s Spec) NonLoopbackBind() bool {
174 host, _, err := net.SplitHostPort(s.BindAddr)
175 if err != nil {
176 return false
177 }
178 ip := net.ParseIP(host)
179 switch {
180 case host == "" || host == "*":
181 return true
182 case ip == nil:
183 return host != "localhost"
184 default:
185 return !ip.IsLoopback()
186 }
187 }
188
189 func parsePort(s string) (int, error) {
190 p, err := strconv.Atoi(strings.TrimSpace(s))
191 if err != nil || p < 0 || p > 65535 {
192 return 0, fmt.Errorf("forward: invalid port %q", s)
193 }
194 return p, nil
195 }
196
197 func parseTargetPort(s string) (int, error) {
198 p, err := parsePort(s)
199 if err != nil || p == 0 {
200 return 0, fmt.Errorf("forward: invalid target port %q", s)
201 }
202 return p, nil
203 }
204
204 lines GO