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 } }