server/ssh/web/conn.go
Rama master · réplica de lectura del repositorio original.
package web
import (
"encoding/json"
"errors"
"io"
"sync"
"time"
"unicode/utf8"
log "github.com/sirupsen/logrus"
"golang.org/x/net/websocket"
)
// Socket is the transport under a [Conn]. It is an interface so tests can drive a connection
// without a real WebSocket.
type Socket interface {
io.ReadWriteCloser
}
// Conn is the browser's end of a web terminal session. Writes are serialised, because the
// terminal, the ping loop and the signing exchange all write to the same socket.
type Conn struct {
// Socket is the internal websocket connection the messages come from.
Socket Socket
// Pinger is reponsable to inform the server that a SSH session is open.
Pinger *time.Ticker
writes sync.Mutex
deadlines sync.Mutex
closed chan struct{}
closing sync.Once
closeErr error
}
var aLongTimeAgo = time.Unix(1, 0)
// NewConn wraps socket, starting the ticker that keeps the session marked as live.
func NewConn(socket Socket) *Conn {
return &Conn{
Socket: socket,
Pinger: time.NewTicker(30 * time.Second),
closed: make(chan struct{}),
}
}
// CharacterSize is the size of a single character in bytes when encoded in UTF-8.
const CharacterSize = 4
// TermniosMaxLineLength is the maximum line length for a terminal input in characters.
//
// [termios] is a POSIX-defined API for configuring terminal I/O settings in Unix-like systems (Linux, macOS, *BSD, etc.).
// It provides fine-grained control over how terminals (TTYs and PTYs) handle input, output, and line discipline
// features like canonical mode, echo, signals, and baud rates.
//
// Essentially, [termios] settings control how the PTY slave, used by our web terminal, processes input and output data.
// It also affects how the slave buffers input, handles special chars (like Ctrl-C), line editing, etc. In canonical
// mode, the terminal processes input line-by-line, meaning it waits for a newline character before sending the data to
// the application. The maximum line length is 4096 characters, any input longer than that is truncated.
//
// [termios] documentation says:
//
// The maximum line length is 4096 chars (including the
// terminating newline character); lines longer than 4096 chars
// are truncated. After 4095 characters, input processing (e.g.,
// ISIG and ECHO* processing) continues, but any input data after
// 4095 characters up to (but not including) any terminating
// newline is discarded. This ensures that the terminal can
// always receive more input until at least one line can be read.
//
// [termios]: https://www.man7.org/linux/man-pages/man3/termios.3.html
const TermniosMaxLineLength = 4096
// ReadMessageBufferSize is the size of the buffer used to read messages from the websocket connection.
//
// As we read JSON messages from the websocket connection, we need to ensure that the buffer size is large enough
// so, we have decided to use a buffer size of 16404 bytes, which is the [TermniosMaxLineLength] plus the size of the
// minimum message size [MessageMinSize].
const ReadMessageBufferSize = MessageMinSize + (TermniosMaxLineLength * CharacterSize)
// ReadMessage decodes the next message into message, returning the bytes consumed. The read
// is bounded by [ReadMessageBufferSize] so that a client cannot force unbounded buffering.
func (c *Conn) ReadMessage(message *Message) (int, error) {
limit := io.LimitReader(c.Socket, ReadMessageBufferSize)
decoder := json.NewDecoder(limit)
var data json.RawMessage
message.Data = &data
if err := decoder.Decode(message); err != nil {
log.WithError(err).Error("failed to read a line from the websocket connection")
return 0, errors.Join(ErrConnReadMessageJSONInvalid, err)
}
switch message.Kind {
case messageKindInput:
var str string
if err := json.Unmarshal(data, &str); err != nil {
return 0, errors.Join(ErrConnReadMessageJSONInvalid)
}
if utf8.RuneCountInString(str) > TermniosMaxLineLength {
return 0, errors.Join(ErrConnReadMessageInputTooLong)
}
message.Data = str
case messageKindResize:
var dim Dimensions
if err := json.Unmarshal(data, &dim); err != nil {
return 0, errors.Join(ErrConnReadMessageJSONInvalid)
}
message.Data = dim
case messageKindSignature:
var sig string
if err := json.Unmarshal(data, &sig); err != nil {
return 0, errors.Join(ErrConnReadMessageJSONInvalid)
}
message.Data = sig
case messageKindReauthDone:
var code string
if err := json.Unmarshal(data, &code); err != nil {
return 0, errors.Join(ErrConnReadMessageJSONInvalid)
}
message.Data = code
default:
return 0, errors.Join(ErrConnReadMessageKindInvalid)
}
return int(decoder.InputOffset()), nil
}
// WriteMessage sends message as JSON, returning the bytes written.
func (c *Conn) WriteMessage(message *Message) (int, error) {
c.writes.Lock()
defer c.writes.Unlock()
buffer, err := json.Marshal(message)
if err != nil {
return 0, errors.Join(ErrConnReadMessageJSONInvalid)
}
wrote, err := c.Socket.Write(buffer)
if err != nil {
return wrote, errors.Join(ErrConnReadMessageSocketWrite, err)
}
return wrote, nil
}
// WriteBinary sends data as a binary frame, which is how terminal output is carried. Non-
// WebSocket sockets fall back to a plain write.
func (c *Conn) WriteBinary(data []byte) (int, error) {
c.writes.Lock()
defer c.writes.Unlock()
socket, ok := c.Socket.(*websocket.Conn)
if !ok {
return c.Socket.Write(data)
}
frame, err := socket.NewFrameWriter(websocket.BinaryFrame)
if err != nil {
return 0, errors.Join(ErrConnWriteMessageFailedFrame, err)
}
wrote, err := frame.Write(data)
if err != nil {
return wrote, errors.Join(ErrConnReadMessageSocketWrite, err)
}
return wrote, nil
}
// WritePing sends a ping frame, telling the browser the session is still open.
func (c *Conn) WritePing() error {
c.writes.Lock()
defer c.writes.Unlock()
socket, ok := c.Socket.(*websocket.Conn)
if !ok {
return nil
}
frame, err := socket.NewFrameWriter(websocket.PingFrame)
if err != nil {
return err
}
if _, err := frame.Write([]byte{}); err != nil {
return err
}
return nil
}
func (c *Conn) Read(buffer []byte) (int, error) {
return c.Socket.Read(buffer)
}
func (c *Conn) Write(buffer []byte) (int, error) {
c.writes.Lock()
defer c.writes.Unlock()
return c.Socket.Write(buffer)
}
// Close stops the ping ticker, ends [Conn.KeepAlive] and closes the socket. When the socket
// implements SetWriteDeadline, a write in flight is failed at once rather than waited for; any
// other socket waits for that write. With no write in flight, the close frame is written under the
// deadline KeepAlive last set, so a browser that stopped reading can hold the close until it
// expires. Only the first call closes anything; every call returns the error of that first socket
// close.
func (c *Conn) Close() error {
c.closing.Do(func() {
c.Pinger.Stop()
close(c.closed)
if !c.writes.TryLock() {
c.expireWrites()
c.writes.Lock()
}
defer c.writes.Unlock()
c.closeErr = c.Socket.Close()
})
return c.closeErr
}
// KeepAlive pings on every tick until the connection is closed. It is meant to be run in its
// own goroutine.
func (c *Conn) KeepAlive() {
socket, ok := c.Socket.(*websocket.Conn)
if !ok {
return
}
for {
if !c.extendDeadline(socket) {
return
}
if err := c.WritePing(); err != nil {
return
}
select {
case <-c.Pinger.C:
case <-c.closed:
return
}
}
}
func (c *Conn) extendDeadline(socket *websocket.Conn) bool {
c.deadlines.Lock()
defer c.deadlines.Unlock()
select {
case <-c.closed:
return false
default:
}
return socket.SetDeadline(time.Now().Add((time.Second*30)*2)) == nil //nolint:forbidigo // a network deadline needs the wall clock
}
func (c *Conn) expireWrites() {
c.deadlines.Lock()
defer c.deadlines.Unlock()
if socket, ok := c.Socket.(interface{ SetWriteDeadline(time.Time) error }); ok {
socket.SetWriteDeadline(aLongTimeAgo) //nolint:errcheck // a refused deadline only delays the close until the stalled write returns
}
}