| 1 | package forward |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "io" |
| 6 | "net" |
| 7 | "sync" |
| 8 | |
| 9 | "golang.org/x/crypto/ssh" |
| 10 | ) |
| 11 | |
| 12 | // Event reports a forward's transition, delivered to the Set's onEvent hook. |
| 13 | type Event struct { |
| 14 | Spec Spec |
| 15 | Up bool |
| 16 | BoundAddr string // actual bound address (resolves ":0") |
| 17 | Err error |
| 18 | } |
| 19 | |
| 20 | // Entry is a snapshot of one registered forward. |
| 21 | type Entry struct { |
| 22 | Spec Spec |
| 23 | Up bool |
| 24 | BoundAddr string |
| 25 | LastErr error |
| 26 | } |
| 27 | |
| 28 | // Set is the port-forward registry for one Client. It is safe for concurrent |
| 29 | // use. Local listeners persist across Detach/Attach so a forwarded port stays |
| 30 | // reserved through reconnects; remote listeners are torn down on Detach and |
| 31 | // recreated on Attach. |
| 32 | type Set struct { |
| 33 | onEvent func(Event) |
| 34 | |
| 35 | mu sync.Mutex |
| 36 | ssh *ssh.Client |
| 37 | runs map[string]*runner |
| 38 | } |
| 39 | |
| 40 | // NewSet creates an empty Set. onEvent may be nil. |
| 41 | func NewSet(onEvent func(Event)) *Set { |
| 42 | return &Set{onEvent: onEvent, runs: map[string]*runner{}} |
| 43 | } |
| 44 | |
| 45 | type runner struct { |
| 46 | spec Spec |
| 47 | local net.Listener // Local: persistent local listener |
| 48 | remote net.Listener // Remote: per-connection remote listener |
| 49 | up bool |
| 50 | lastErr error |
| 51 | stop chan struct{} |
| 52 | acceptWG sync.WaitGroup |
| 53 | } |
| 54 | |
| 55 | // Add registers spec and starts it if the Set is attached. Returns the bound |
| 56 | // address (useful for ":0" local forwards). |
| 57 | func (s *Set) Add(spec Spec) (string, error) { |
| 58 | if err := spec.Validate(); err != nil { |
| 59 | return "", err |
| 60 | } |
| 61 | name := spec.DefaultName() |
| 62 | spec.Name = name |
| 63 | s.mu.Lock() |
| 64 | defer s.mu.Unlock() |
| 65 | if _, ok := s.runs[name]; ok { |
| 66 | return "", ErrDuplicateForward |
| 67 | } |
| 68 | r := &runner{spec: spec, stop: make(chan struct{})} |
| 69 | s.runs[name] = r |
| 70 | if s.ssh == nil { |
| 71 | return "", nil // starts on Attach |
| 72 | } |
| 73 | bound, err := s.startLocked(r, s.ssh) |
| 74 | if err != nil { |
| 75 | delete(s.runs, name) |
| 76 | return "", err |
| 77 | } |
| 78 | return bound, nil |
| 79 | } |
| 80 | |
| 81 | // Replace atomically swaps the named registration after the replacement has |
| 82 | // started successfully. If startup fails, the existing forward remains live. |
| 83 | // This is primarily used when a remote serve moves to a new workspace/port. |
| 84 | func (s *Set) Replace(spec Spec) (string, error) { |
| 85 | if err := spec.Validate(); err != nil { |
| 86 | return "", err |
| 87 | } |
| 88 | name := spec.DefaultName() |
| 89 | spec.Name = name |
| 90 | s.mu.Lock() |
| 91 | old := s.runs[name] |
| 92 | if s.ssh == nil { |
| 93 | s.mu.Unlock() |
| 94 | return "", ErrNotAttached |
| 95 | } |
| 96 | replacement := &runner{spec: spec, stop: make(chan struct{})} |
| 97 | bound := "" |
| 98 | bound, err := s.startLocked(replacement, s.ssh) |
| 99 | if err != nil { |
| 100 | s.mu.Unlock() |
| 101 | return "", err |
| 102 | } |
| 103 | s.runs[name] = replacement |
| 104 | if old != nil { |
| 105 | // The replacement's Up event is authoritative; suppress a later Down event |
| 106 | // from retiring the old runner with the same name. |
| 107 | old.up = false |
| 108 | } |
| 109 | s.mu.Unlock() |
| 110 | if old != nil { |
| 111 | s.stopRunner(old, true) |
| 112 | } |
| 113 | return bound, nil |
| 114 | } |
| 115 | |
| 116 | // Remove stops and deregisters the named forward. |
| 117 | func (s *Set) Remove(name string) error { |
| 118 | s.mu.Lock() |
| 119 | r, ok := s.runs[name] |
| 120 | if ok { |
| 121 | delete(s.runs, name) |
| 122 | } |
| 123 | s.mu.Unlock() |
| 124 | if !ok { |
| 125 | return errors.New("forward: no such forward: " + name) |
| 126 | } |
| 127 | s.stopRunner(r, true) |
| 128 | return nil |
| 129 | } |
| 130 | |
| 131 | // List snapshots all registered forwards. |
| 132 | func (s *Set) List() []Entry { |
| 133 | s.mu.Lock() |
| 134 | defer s.mu.Unlock() |
| 135 | out := make([]Entry, 0, len(s.runs)) |
| 136 | for _, r := range s.runs { |
| 137 | bound := "" |
| 138 | if r.local != nil { |
| 139 | bound = r.local.Addr().String() |
| 140 | } else if r.remote != nil { |
| 141 | bound = r.remote.Addr().String() |
| 142 | } |
| 143 | out = append(out, Entry{Spec: r.spec, Up: r.up, BoundAddr: bound, LastErr: r.lastErr}) |
| 144 | } |
| 145 | return out |
| 146 | } |
| 147 | |
| 148 | // Attach binds the Set to a (re)connected ssh client and (re)starts every |
| 149 | // forward. Per-forward failures are joined and returned; successfully started |
| 150 | // forwards stay up. |
| 151 | func (s *Set) Attach(cl *ssh.Client) error { |
| 152 | s.mu.Lock() |
| 153 | defer s.mu.Unlock() |
| 154 | s.ssh = cl |
| 155 | var errs []error |
| 156 | for _, r := range s.runs { |
| 157 | if _, err := s.startLocked(r, cl); err != nil { |
| 158 | errs = append(errs, err) |
| 159 | } |
| 160 | } |
| 161 | return errors.Join(errs...) |
| 162 | } |
| 163 | |
| 164 | // Detach drops the current connection. Local listeners stay open (and refuse |
| 165 | // data until re-attached); remote listeners are closed. |
| 166 | func (s *Set) Detach() { |
| 167 | s.mu.Lock() |
| 168 | defer s.mu.Unlock() |
| 169 | s.ssh = nil |
| 170 | for _, r := range s.runs { |
| 171 | if r.remote != nil { |
| 172 | _ = r.remote.Close() |
| 173 | r.remote = nil |
| 174 | } |
| 175 | if r.up { |
| 176 | r.up = false |
| 177 | s.emit(Event{Spec: r.spec, Up: false}) |
| 178 | } |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | // Close stops all forwards and releases every listener. |
| 183 | func (s *Set) Close() { |
| 184 | s.mu.Lock() |
| 185 | runs := s.runs |
| 186 | s.runs = map[string]*runner{} |
| 187 | s.ssh = nil |
| 188 | s.mu.Unlock() |
| 189 | for _, r := range runs { |
| 190 | s.stopRunner(r, true) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | // startLocked starts (or restarts) r on cl. Caller holds s.mu. |
| 195 | func (s *Set) startLocked(r *runner, cl *ssh.Client) (string, error) { |
| 196 | if r.spec.Direction == Local { |
| 197 | return s.startLocalLocked(r, cl) |
| 198 | } |
| 199 | return s.startRemoteLocked(r, cl) |
| 200 | } |
| 201 | |
| 202 | func (s *Set) startLocalLocked(r *runner, cl *ssh.Client) (string, error) { |
| 203 | if r.local == nil { |
| 204 | ln, err := net.Listen("tcp", r.spec.BindAddr) |
| 205 | if err != nil { |
| 206 | r.lastErr = wrapBind(err) |
| 207 | s.emit(Event{Spec: r.spec, Up: false, Err: r.lastErr}) |
| 208 | return "", r.lastErr |
| 209 | } |
| 210 | r.local = ln |
| 211 | r.acceptWG.Add(1) |
| 212 | go s.acceptLocal(r) |
| 213 | } |
| 214 | r.up = true |
| 215 | r.lastErr = nil |
| 216 | bound := r.local.Addr().String() |
| 217 | s.emit(Event{Spec: r.spec, Up: true, BoundAddr: bound}) |
| 218 | return bound, nil |
| 219 | } |
| 220 | |
| 221 | // acceptLocal accepts on the persistent local listener. Each accepted conn is |
| 222 | // forwarded through whatever ssh client is current at dial time; when detached |
| 223 | // (ssh == nil) the conn is refused. |
| 224 | func (s *Set) acceptLocal(r *runner) { |
| 225 | defer r.acceptWG.Done() |
| 226 | for { |
| 227 | conn, err := r.local.Accept() |
| 228 | if err != nil { |
| 229 | select { |
| 230 | case <-r.stop: |
| 231 | return |
| 232 | default: |
| 233 | return // listener closed |
| 234 | } |
| 235 | } |
| 236 | go s.handleLocalConn(r, conn) |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | func (s *Set) handleLocalConn(r *runner, local net.Conn) { |
| 241 | s.mu.Lock() |
| 242 | cl := s.ssh |
| 243 | s.mu.Unlock() |
| 244 | if cl == nil { |
| 245 | _ = local.Close() |
| 246 | return |
| 247 | } |
| 248 | remote, err := cl.Dial("tcp", r.spec.TargetAddr) |
| 249 | if err != nil { |
| 250 | _ = local.Close() |
| 251 | return |
| 252 | } |
| 253 | pipe(local, remote) |
| 254 | } |
| 255 | |
| 256 | func (s *Set) startRemoteLocked(r *runner, cl *ssh.Client) (string, error) { |
| 257 | ln, err := cl.Listen("tcp", r.spec.BindAddr) |
| 258 | if err != nil { |
| 259 | r.lastErr = wrapBind(err) |
| 260 | s.emit(Event{Spec: r.spec, Up: false, Err: r.lastErr}) |
| 261 | return "", r.lastErr |
| 262 | } |
| 263 | r.remote = ln |
| 264 | r.up = true |
| 265 | r.lastErr = nil |
| 266 | go s.acceptRemote(r, ln) |
| 267 | bound := ln.Addr().String() |
| 268 | s.emit(Event{Spec: r.spec, Up: true, BoundAddr: bound}) |
| 269 | return bound, nil |
| 270 | } |
| 271 | |
| 272 | func (s *Set) acceptRemote(r *runner, ln net.Listener) { |
| 273 | for { |
| 274 | remote, err := ln.Accept() |
| 275 | if err != nil { |
| 276 | return |
| 277 | } |
| 278 | go func() { |
| 279 | local, derr := net.Dial("tcp", r.spec.TargetAddr) |
| 280 | if derr != nil { |
| 281 | _ = remote.Close() |
| 282 | return |
| 283 | } |
| 284 | pipe(remote, local) |
| 285 | }() |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | func (s *Set) stopRunner(r *runner, closeLocal bool) { |
| 290 | close(r.stop) |
| 291 | if r.remote != nil { |
| 292 | _ = r.remote.Close() |
| 293 | r.remote = nil |
| 294 | } |
| 295 | if closeLocal && r.local != nil { |
| 296 | _ = r.local.Close() |
| 297 | } |
| 298 | r.acceptWG.Wait() |
| 299 | if closeLocal { |
| 300 | r.local = nil |
| 301 | } |
| 302 | if r.up { |
| 303 | r.up = false |
| 304 | s.emit(Event{Spec: r.spec, Up: false}) |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | func (s *Set) emit(e Event) { |
| 309 | if s.onEvent != nil { |
| 310 | s.onEvent(e) |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | // pipe copies bidirectionally between a and b, closing both when either side |
| 315 | // ends. Half-close is best-effort via CloseWrite when supported. |
| 316 | func pipe(a, b net.Conn) { |
| 317 | done := make(chan struct{}, 2) |
| 318 | cp := func(dst, src net.Conn) { |
| 319 | _, _ = io.Copy(dst, src) |
| 320 | if cw, ok := dst.(interface{ CloseWrite() error }); ok { |
| 321 | _ = cw.CloseWrite() |
| 322 | } |
| 323 | done <- struct{}{} |
| 324 | } |
| 325 | go cp(a, b) |
| 326 | go cp(b, a) |
| 327 | <-done |
| 328 | _ = a.Close() |
| 329 | _ = b.Close() |
| 330 | } |
| 331 | |
| 332 | func wrapBind(err error) error { |
| 333 | if isAddrInUse(err) { |
| 334 | return errors.Join(ErrBindBusy, err) |
| 335 | } |
| 336 | return err |
| 337 | } |
| 338 |