Files
hop-gate/internal/protocol/codec.go
copilot-swe-agent[bot] 1292df33e5 Fix DTLS buffer size issue by wrapping sessions with buffered readers
- Add dtlsReadBufferSize constant (8KB) matching pion/dtls limits
- Wrap DTLS sessions with bufio.Reader in client and server code
- Update tests to use buffered readers for datagram-based connections
- All tests passing successfully

Co-authored-by: dalbodeule <11470513+dalbodeule@users.noreply.github.com>
2025-12-09 14:07:15 +00:00

428 lines
14 KiB
Go

package protocol
import (
"bufio"
"encoding/binary"
"encoding/json"
"fmt"
"io"
protocolpb "github.com/dalbodeule/hop-gate/internal/protocol/pb"
"google.golang.org/protobuf/proto"
)
// defaultDecoderBufferSize 는 pion/dtls 가 복호화한 애플리케이션 데이터를
// JSON 디코더가 안전하게 처리할 수 있도록 사용하는 버퍼 크기입니다.
// This matches existing 64KiB readers used around DTLS sessions (used by the JSON codec).
const defaultDecoderBufferSize = 64 * 1024
// dtlsReadBufferSize 는 pion/dtls 내부 버퍼 한계에 맞춘 읽기 버퍼 크기입니다.
// pion/dtls 의 UnpackDatagram 함수는 8KB (8,192 bytes) 의 기본 수신 버퍼를 사용합니다.
// DTLS는 UDP 기반이므로 한 번의 Read()에서 전체 datagram을 읽어야 하며,
// 이 크기를 초과하는 DTLS 레코드는 처리되지 않습니다.
// dtlsReadBufferSize matches the pion/dtls internal buffer limit.
// pion/dtls's UnpackDatagram function uses an 8KB (8,192 bytes) receive buffer.
// Since DTLS is UDP-based, the entire datagram must be read in a single Read() call,
// and DTLS records exceeding this size cannot be processed.
const dtlsReadBufferSize = 8 * 1024 // 8KB
// maxProtoEnvelopeBytes 는 단일 Protobuf Envelope 의 최대 크기에 대한 보수적 상한입니다.
// 아직 하드 리미트로 사용하지는 않지만, 향후 방어적 체크에 사용할 수 있습니다.
const maxProtoEnvelopeBytes = 512 * 1024 // 512KiB, 충분히 여유 있는 값
// WireCodec 는 protocol.Envelope 의 직렬화/역직렬화를 추상화합니다.
// JSON, Protobuf, length-prefixed binary 등으로 교체할 때 이 인터페이스만 유지하면 됩니다.
type WireCodec interface {
Encode(w io.Writer, env *Envelope) error
Decode(r io.Reader, env *Envelope) error
}
// jsonCodec 은 JSON 기반 WireCodec 구현입니다.
// JSON 직렬화를 계속 사용하고 싶을 때를 위해 남겨둡니다.
type jsonCodec struct{}
// Encode 는 Envelope 를 JSON 으로 인코딩해 작성합니다.
// Encode encodes an Envelope as JSON to the given writer.
func (jsonCodec) Encode(w io.Writer, env *Envelope) error {
enc := json.NewEncoder(w)
return enc.Encode(env)
}
// Decode 는 DTLS 세션에서 읽은 데이터를 JSON Envelope 로 디코딩합니다.
// pion/dtls 의 버퍼 특성 때문에, 충분히 큰 bufio.Reader 로 감싸서 사용합니다.
// Decode decodes an Envelope from JSON using a buffered reader on top of the DTLS session.
func (jsonCodec) Decode(r io.Reader, env *Envelope) error {
dec := json.NewDecoder(bufio.NewReaderSize(r, defaultDecoderBufferSize))
return dec.Decode(env)
}
// protobufCodec 은 Protobuf length-prefix framing 기반 WireCodec 구현입니다.
// 한 Envelope 당 [4바이트 big-endian 길이] [protobuf bytes] 형태로 인코딩합니다.
type protobufCodec struct{}
// Encode 는 Envelope 를 Protobuf Envelope 로 변환한 뒤, length-prefix 프레이밍으로 기록합니다.
// DTLS는 UDP 기반이므로, length prefix와 protobuf 데이터를 단일 버퍼로 합쳐 하나의 Write로 전송합니다.
// Encode encodes an Envelope as a length-prefixed protobuf message.
// For DTLS (UDP-based), we combine the length prefix and protobuf data into a single buffer
// and send it with a single Write call to preserve message boundaries.
func (protobufCodec) Encode(w io.Writer, env *Envelope) error {
pbEnv, err := toProtoEnvelope(env)
if err != nil {
return err
}
// Body/stream payload 하드 리밋: 4KiB (StreamChunkSize).
// HTTP 단일 Envelope 및 스트림 기반 프레임 모두에서 payload 가 이 값을 넘지 않도록 강제합니다.
// Enforce a 4KiB hard limit (StreamChunkSize) for HTTP bodies and stream payloads.
switch env.Type {
case MessageTypeHTTP:
if env.HTTPRequest != nil && len(env.HTTPRequest.Body) > int(StreamChunkSize) {
return fmt.Errorf("protobuf codec: http request body too large: %d bytes (max %d)", len(env.HTTPRequest.Body), StreamChunkSize)
}
if env.HTTPResponse != nil && len(env.HTTPResponse.Body) > int(StreamChunkSize) {
return fmt.Errorf("protobuf codec: http response body too large: %d bytes (max %d)", len(env.HTTPResponse.Body), StreamChunkSize)
}
case MessageTypeStreamData:
if env.StreamData != nil && len(env.StreamData.Data) > int(StreamChunkSize) {
return fmt.Errorf("protobuf codec: stream data payload too large: %d bytes (max %d)", len(env.StreamData.Data), StreamChunkSize)
}
}
data, err := proto.Marshal(pbEnv)
if err != nil {
return fmt.Errorf("protobuf marshal envelope: %w", err)
}
if len(data) == 0 {
return fmt.Errorf("protobuf codec: empty marshaled envelope")
}
if len(data) > int(^uint32(0)) {
return fmt.Errorf("protobuf codec: envelope too large: %d bytes", len(data))
}
// DTLS 환경에서는 length prefix와 protobuf 데이터를 단일 버퍼로 합쳐서 하나의 Write로 전송
// For DTLS, combine length prefix and protobuf data into a single buffer
frame := make([]byte, 4+len(data))
binary.BigEndian.PutUint32(frame[:4], uint32(len(data)))
copy(frame[4:], data)
if _, err := w.Write(frame); err != nil {
return fmt.Errorf("protobuf codec: write frame: %w", err)
}
return nil
}
// Decode 는 length-prefix 프레임에서 Protobuf Envelope 를 읽어들여
// 내부 Envelope 구조체로 변환합니다.
// DTLS는 UDP 기반이므로, 한 번의 Read로 전체 데이터그램을 읽습니다.
// Decode reads a length-prefixed protobuf Envelope and converts it into the internal Envelope.
// For DTLS (UDP-based), we read the entire datagram in a single Read call.
func (protobufCodec) Decode(r io.Reader, env *Envelope) error {
// 1) 길이 prefix 4바이트를 정확히 읽는다.
header := make([]byte, 4)
if _, err := io.ReadFull(r, header); err != nil {
return fmt.Errorf("protobuf codec: read length prefix: %w", err)
}
length := binary.BigEndian.Uint32(header)
if length == 0 {
return fmt.Errorf("protobuf codec: zero-length envelope")
}
if length > maxProtoEnvelopeBytes {
return fmt.Errorf("protobuf codec: envelope too large: %d bytes (max %d)", length, maxProtoEnvelopeBytes)
}
// 2) payload 를 length 바이트만큼 정확히 읽는다.
payload := make([]byte, int(length))
if _, err := io.ReadFull(r, payload); err != nil {
return fmt.Errorf("protobuf codec: read payload: %w", err)
}
var pbEnv protocolpb.Envelope
if err := proto.Unmarshal(payload, &pbEnv); err != nil {
return fmt.Errorf("protobuf codec: unmarshal envelope: %w", err)
}
return fromProtoEnvelope(&pbEnv, env)
}
// DefaultCodec 은 현재 런타임에서 사용하는 기본 WireCodec 입니다.
// 현재는 Protobuf length-prefix 기반 codec 을 기본으로 사용합니다.
// 서버와 클라이언트가 모두 이 버전을 사용해야 wire-format 이 일치합니다.
var DefaultCodec WireCodec = protobufCodec{}
// GetDTLSReadBufferSize 는 DTLS 세션 읽기에 사용할 버퍼 크기를 반환합니다.
// 이 값은 pion/dtls 내부 버퍼 한계(8KB)에 맞춰져 있습니다.
// GetDTLSReadBufferSize returns the buffer size to use for reading from DTLS sessions.
// This value is aligned with pion/dtls's internal buffer limit (8KB).
func GetDTLSReadBufferSize() int {
return dtlsReadBufferSize
}
// toProtoEnvelope 는 내부 Envelope 구조체를 Protobuf Envelope 로 변환합니다.
// 현재 구현은 HTTP 요청/응답 및 스트림 관련 타입(StreamOpen/StreamData/StreamClose/StreamAck)을 지원합니다.
func toProtoEnvelope(env *Envelope) (*protocolpb.Envelope, error) {
switch env.Type {
case MessageTypeHTTP:
if env.HTTPRequest != nil {
req := env.HTTPRequest
pbReq := &protocolpb.Request{
RequestId: req.RequestID,
ClientId: req.ClientID,
ServiceName: req.ServiceName,
Method: req.Method,
Url: req.URL,
Header: make(map[string]*protocolpb.HeaderValues, len(req.Header)),
Body: req.Body,
}
for k, vs := range req.Header {
hv := &protocolpb.HeaderValues{
Values: append([]string(nil), vs...),
}
pbReq.Header[k] = hv
}
return &protocolpb.Envelope{
Payload: &protocolpb.Envelope_HttpRequest{
HttpRequest: pbReq,
},
}, nil
}
if env.HTTPResponse != nil {
resp := env.HTTPResponse
pbResp := &protocolpb.Response{
RequestId: resp.RequestID,
Status: int32(resp.Status),
Header: make(map[string]*protocolpb.HeaderValues, len(resp.Header)),
Body: resp.Body,
Error: resp.Error,
}
for k, vs := range resp.Header {
hv := &protocolpb.HeaderValues{
Values: append([]string(nil), vs...),
}
pbResp.Header[k] = hv
}
return &protocolpb.Envelope{
Payload: &protocolpb.Envelope_HttpResponse{
HttpResponse: pbResp,
},
}, nil
}
return nil, fmt.Errorf("protobuf codec: http envelope has neither request nor response")
case MessageTypeStreamOpen:
if env.StreamOpen == nil {
return nil, fmt.Errorf("protobuf codec: stream_open envelope missing payload")
}
so := env.StreamOpen
pbSO := &protocolpb.StreamOpen{
Id: string(so.ID),
ServiceName: so.Service,
TargetAddr: so.TargetAddr,
Header: make(map[string]*protocolpb.HeaderValues, len(so.Header)),
}
for k, vs := range so.Header {
hv := &protocolpb.HeaderValues{
Values: append([]string(nil), vs...),
}
pbSO.Header[k] = hv
}
return &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamOpen{
StreamOpen: pbSO,
},
}, nil
case MessageTypeStreamData:
if env.StreamData == nil {
return nil, fmt.Errorf("protobuf codec: stream_data envelope missing payload")
}
sd := env.StreamData
pbSD := &protocolpb.StreamData{
Id: string(sd.ID),
Seq: sd.Seq,
Data: sd.Data,
}
return &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamData{
StreamData: pbSD,
},
}, nil
case MessageTypeStreamClose:
if env.StreamClose == nil {
return nil, fmt.Errorf("protobuf codec: stream_close envelope missing payload")
}
sc := env.StreamClose
pbSC := &protocolpb.StreamClose{
Id: string(sc.ID),
Error: sc.Error,
}
return &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamClose{
StreamClose: pbSC,
},
}, nil
case MessageTypeStreamAck:
if env.StreamAck == nil {
return nil, fmt.Errorf("protobuf codec: stream_ack envelope missing payload")
}
sa := env.StreamAck
pbSA := &protocolpb.StreamAck{
Id: string(sa.ID),
AckSeq: sa.AckSeq,
LostSeqs: append([]uint64(nil), sa.LostSeqs...),
WindowSize: sa.WindowSize,
}
return &protocolpb.Envelope{
Payload: &protocolpb.Envelope_StreamAck{
StreamAck: pbSA,
},
}, nil
default:
return nil, fmt.Errorf("protobuf codec: unsupported envelope type %q", env.Type)
}
}
// fromProtoEnvelope 는 Protobuf Envelope 를 내부 Envelope 구조체로 변환합니다.
// 현재 구현은 HTTP 요청/응답 및 스트림 관련 타입(StreamOpen/StreamData/StreamClose/StreamAck)을 지원합니다.
func fromProtoEnvelope(pbEnv *protocolpb.Envelope, env *Envelope) error {
switch payload := pbEnv.Payload.(type) {
case *protocolpb.Envelope_HttpRequest:
req := payload.HttpRequest
if req == nil {
return fmt.Errorf("protobuf codec: http_request payload is nil")
}
hdr := make(map[string][]string, len(req.Header))
for k, hv := range req.Header {
if hv == nil {
continue
}
hdr[k] = append([]string(nil), hv.Values...)
}
env.Type = MessageTypeHTTP
env.HTTPRequest = &Request{
RequestID: req.RequestId,
ClientID: req.ClientId,
ServiceName: req.ServiceName,
Method: req.Method,
URL: req.Url,
Header: hdr,
Body: append([]byte(nil), req.Body...),
}
env.HTTPResponse = nil
env.StreamOpen = nil
env.StreamData = nil
env.StreamClose = nil
env.StreamAck = nil
return nil
case *protocolpb.Envelope_HttpResponse:
resp := payload.HttpResponse
if resp == nil {
return fmt.Errorf("protobuf codec: http_response payload is nil")
}
hdr := make(map[string][]string, len(resp.Header))
for k, hv := range resp.Header {
if hv == nil {
continue
}
hdr[k] = append([]string(nil), hv.Values...)
}
env.Type = MessageTypeHTTP
env.HTTPResponse = &Response{
RequestID: resp.RequestId,
Status: int(resp.Status),
Header: hdr,
Body: append([]byte(nil), resp.Body...),
Error: resp.Error,
}
env.HTTPRequest = nil
env.StreamOpen = nil
env.StreamData = nil
env.StreamClose = nil
env.StreamAck = nil
return nil
case *protocolpb.Envelope_StreamOpen:
so := payload.StreamOpen
if so == nil {
return fmt.Errorf("protobuf codec: stream_open payload is nil")
}
hdr := make(map[string][]string, len(so.Header))
for k, hv := range so.Header {
if hv == nil {
continue
}
hdr[k] = append([]string(nil), hv.Values...)
}
env.Type = MessageTypeStreamOpen
env.StreamOpen = &StreamOpen{
ID: StreamID(so.Id),
Service: so.ServiceName,
TargetAddr: so.TargetAddr,
Header: hdr,
}
env.StreamData = nil
env.StreamClose = nil
env.StreamAck = nil
env.HTTPRequest = nil
env.HTTPResponse = nil
return nil
case *protocolpb.Envelope_StreamData:
sd := payload.StreamData
if sd == nil {
return fmt.Errorf("protobuf codec: stream_data payload is nil")
}
env.Type = MessageTypeStreamData
env.StreamData = &StreamData{
ID: StreamID(sd.Id),
Seq: sd.Seq,
Data: append([]byte(nil), sd.Data...),
}
env.StreamOpen = nil
env.StreamClose = nil
env.StreamAck = nil
env.HTTPRequest = nil
env.HTTPResponse = nil
return nil
case *protocolpb.Envelope_StreamClose:
sc := payload.StreamClose
if sc == nil {
return fmt.Errorf("protobuf codec: stream_close payload is nil")
}
env.Type = MessageTypeStreamClose
env.StreamClose = &StreamClose{
ID: StreamID(sc.Id),
Error: sc.Error,
}
env.StreamOpen = nil
env.StreamData = nil
env.StreamAck = nil
env.HTTPRequest = nil
env.HTTPResponse = nil
return nil
case *protocolpb.Envelope_StreamAck:
sa := payload.StreamAck
if sa == nil {
return fmt.Errorf("protobuf codec: stream_ack payload is nil")
}
env.Type = MessageTypeStreamAck
env.StreamAck = &StreamAck{
ID: StreamID(sa.Id),
AckSeq: sa.AckSeq,
LostSeqs: append([]uint64(nil), sa.LostSeqs...),
WindowSize: sa.WindowSize,
}
env.StreamOpen = nil
env.StreamData = nil
env.StreamClose = nil
env.HTTPRequest = nil
env.HTTPResponse = nil
return nil
default:
return fmt.Errorf("protobuf codec: unsupported payload type %T", payload)
}
}