Files
the-other-dude/poller/internal/sshrelay/bridge.go
Jason Staack c73466c5e0 feat(poller): add SSH relay server with WebSocket-to-PTY bridge
Implements the SSH relay server (Task 2.1) that validates single-use
Redis tokens via GETDEL, dials SSH to the target device with PTY,
and bridges WebSocket binary/text frames to SSH stdin/stdout/stderr
with idle timeout and per-user/per-device session limits.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:33:48 -05:00

76 lines
1.4 KiB
Go

package sshrelay
import (
"context"
"encoding/json"
"io"
"sync/atomic"
"time"
"golang.org/x/crypto/ssh"
"nhooyr.io/websocket"
)
type ControlMsg struct {
Type string `json:"type"`
Cols int `json:"cols"`
Rows int `json:"rows"`
}
func bridge(ctx context.Context, cancel context.CancelFunc, ws *websocket.Conn,
sshSess *ssh.Session, stdin io.WriteCloser, stdout, stderr io.Reader, lastActive *int64) {
// WebSocket → SSH stdin
go func() {
defer cancel()
for {
typ, data, err := ws.Read(ctx)
if err != nil {
return
}
atomic.StoreInt64(lastActive, time.Now().UnixNano())
if typ == websocket.MessageText {
var ctrl ControlMsg
if json.Unmarshal(data, &ctrl) != nil {
continue
}
if ctrl.Type == "resize" && ctrl.Cols > 0 && ctrl.Cols <= 500 && ctrl.Rows > 0 && ctrl.Rows <= 200 {
sshSess.WindowChange(ctrl.Rows, ctrl.Cols)
}
continue
}
stdin.Write(data)
}
}()
// SSH stdout → WebSocket
go func() {
defer cancel()
buf := make([]byte, 4096)
for {
n, err := stdout.Read(buf)
if err != nil {
return
}
atomic.StoreInt64(lastActive, time.Now().UnixNano())
ws.Write(ctx, websocket.MessageBinary, buf[:n])
}
}()
// SSH stderr → WebSocket
go func() {
defer cancel()
buf := make([]byte, 4096)
for {
n, err := stderr.Read(buf)
if err != nil {
return
}
ws.Write(ctx, websocket.MessageBinary, buf[:n])
}
}()
<-ctx.Done()
}